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);
|
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]
|
[Fact]
|
||||||
public async Task EndpointReportsProviderFailuresAsBadGateway()
|
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 sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||||
{
|
{
|
||||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||||
|
|||||||
@@ -100,7 +100,27 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
|||||||
Assert.Null(match);
|
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(
|
return new AzureSpeechSpeakerIdentityMatcher(
|
||||||
diarizationClient,
|
diarizationClient,
|
||||||
@@ -108,7 +128,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
|||||||
{
|
{
|
||||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||||
{
|
{
|
||||||
SilenceBetweenSnippetsSeconds = 1
|
SilenceBetweenSnippetsSeconds = 1,
|
||||||
|
MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
|
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
|
||||||
@@ -145,4 +166,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
|||||||
return Task.FromResult(segments);
|
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.Recording;
|
||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
|
using Microsoft.CognitiveServices.Speech;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
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]
|
[Fact]
|
||||||
public void ResolveKeyFallsBackToUserEnvironment()
|
public void ResolveKeyFallsBackToUserEnvironment()
|
||||||
{
|
{
|
||||||
@@ -82,15 +127,17 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EndpointDefaultsToUniversalV2SpeechEndpointForRegion()
|
public void CreateSpeechConfigUsesSubscriptionWhenEndpointIsBlank()
|
||||||
{
|
{
|
||||||
Assert.Equal(
|
var speechConfig = AzureSpeechStreamingTranscriptionProvider.CreateSpeechConfig(
|
||||||
new Uri("wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2"),
|
new AzureSpeechOptions
|
||||||
AzureSpeechStreamingTranscriptionProvider.GetEndpoint(new AzureSpeechOptions
|
|
||||||
{
|
{
|
||||||
Endpoint = "",
|
Endpoint = "",
|
||||||
Region = "germanywestcentral"
|
Region = "westeurope"
|
||||||
}));
|
},
|
||||||
|
"test-key");
|
||||||
|
|
||||||
|
Assert.NotNull(speechConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
|
using MeetingAssistant.Recording;
|
||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -24,11 +27,101 @@ public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
|
|||||||
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||||
provider,
|
[new AzureSpeechRecognitionPipelineBuilder(provider)],
|
||||||
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
|
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
|
||||||
|
|
||||||
await using var pipeline = factory.Create();
|
await using var pipeline = factory.Create();
|
||||||
|
|
||||||
Assert.IsType<AzureSpeechRecognitionPipeline>(pipeline);
|
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"),
|
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||||
AssistantContextPath: contextPath,
|
AssistantContextPath: contextPath,
|
||||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
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(
|
await artifactStore.CreateAssistantContextAsync(
|
||||||
artifacts,
|
artifacts,
|
||||||
|
meetingNote,
|
||||||
"Review previous decisions\nAgree next steps",
|
"Review previous decisions\nAgree next steps",
|
||||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
@@ -26,7 +30,9 @@ public sealed class MeetingArtifactStoreTests
|
|||||||
var content = await File.ReadAllTextAsync(contextPath);
|
var content = await File.ReadAllTextAsync(contextPath);
|
||||||
Assert.Contains("# Assistant Context", content);
|
Assert.Contains("# Assistant Context", content);
|
||||||
Assert.StartsWith("---", content, StringComparison.Ordinal);
|
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("agenda: |-", content);
|
||||||
Assert.Contains(" Review previous decisions", content);
|
Assert.Contains(" Review previous decisions", content);
|
||||||
Assert.Contains(" Agree next steps", 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("meeting: \"[[../Notes/20260519-meeting|Meeting Note]]\"", content);
|
||||||
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
|
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
|
||||||
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
|
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
|
||||||
|
Assert.DoesNotContain("assistant_context:", content);
|
||||||
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
|
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
|
||||||
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
|
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
|
||||||
Assert.Contains("[[../Summaries/20260519-summary|Summary]]", 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"),
|
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||||
AssistantContextPath: contextPath,
|
AssistantContextPath: contextPath,
|
||||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
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(
|
await artifactStore.CreateAssistantContextAsync(
|
||||||
artifacts,
|
artifacts,
|
||||||
|
meetingNote,
|
||||||
"Initial agenda",
|
"Initial agenda",
|
||||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
@@ -65,6 +76,8 @@ public sealed class MeetingArtifactStoreTests
|
|||||||
|
|
||||||
var content = await File.ReadAllTextAsync(contextPath);
|
var content = await File.ReadAllTextAsync(contextPath);
|
||||||
Assert.Contains("state: summarizing", content);
|
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("agenda: |-", content);
|
||||||
Assert.Contains(" Initial agenda", content);
|
Assert.Contains(" Initial agenda", content);
|
||||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", 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"),
|
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||||
AssistantContextPath: contextPath,
|
AssistantContextPath: contextPath,
|
||||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
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);
|
var content = await File.ReadAllTextAsync(contextPath);
|
||||||
Assert.DoesNotContain("scheduled_end:", content);
|
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);
|
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("Summary Generation Failed", content);
|
||||||
Assert.Contains("title: Failure Meeting", content);
|
Assert.Contains("title: Failure Meeting", content);
|
||||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", 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(
|
Assert.Contains(
|
||||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
|
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
|
||||||
content);
|
content);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Summary;
|
using MeetingAssistant.Summary;
|
||||||
|
using MeetingAssistant.Transcription;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace MeetingAssistant.Tests;
|
namespace MeetingAssistant.Tests;
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ public sealed class MeetingSummaryToolTests
|
|||||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
|
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
|
||||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
|
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
|
||||||
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
|
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
|
||||||
|
Assert.DoesNotContain("summary:", summary);
|
||||||
Assert.Contains("# Summary\n\n- Done.", summary);
|
Assert.Contains("# Summary\n\n- Done.", summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,4 +145,63 @@ public sealed class MeetingSummaryToolTests
|
|||||||
Assert.Contains("state: summarizing", context);
|
Assert.Contains("state: summarizing", context);
|
||||||
Assert.Contains("replacement\ninserted\nline two", 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.Recording;
|
||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.Speakers;
|
using MeetingAssistant.Speakers;
|
||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Summary;
|
using MeetingAssistant.Summary;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using System.Threading.Channels;
|
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.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\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
|
||||||
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, 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);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -137,12 +141,47 @@ public sealed class RecordingCoordinatorTests
|
|||||||
|
|
||||||
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
Assert.Equal("Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||||
Assert.Equal(["Ada <ada@example.com>", "Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
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("Review API shape", artifactStore.Agenda);
|
||||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||||
|
|
||||||
await coordinator.StopAsync(CancellationToken.None);
|
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]
|
[Fact]
|
||||||
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
|
public async Task StartDoesNotWaitForSlowMeetingMetadataButAppliesItLater()
|
||||||
{
|
{
|
||||||
@@ -182,6 +221,61 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
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]
|
[Fact]
|
||||||
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
|
public async Task StartCreatesMeetingNoteWhenMetadataProviderFails()
|
||||||
{
|
{
|
||||||
@@ -300,6 +394,63 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
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]
|
[Fact]
|
||||||
public async Task StartPassesDictationWordsToSpeechPipeline()
|
public async Task StartPassesDictationWordsToSpeechPipeline()
|
||||||
{
|
{
|
||||||
@@ -512,9 +663,9 @@ public sealed class RecordingCoordinatorTests
|
|||||||
|
|
||||||
await coordinator.StartAsync(CancellationToken.None);
|
await coordinator.StartAsync(CancellationToken.None);
|
||||||
await audioSource.WriteAsync(new AudioChunk(Samples(0, 1, 2, 3, 4, 5, 6, 7), 4, 1), 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 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);
|
Assert.Equal("Chris", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||||
await Task.Delay(50);
|
await Task.Delay(50);
|
||||||
await audioSource.WriteAsync(new AudioChunk(Samples(8, 9, 10, 11, 12, 13, 14, 15), 4, 1), CancellationToken.None);
|
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));
|
second => Assert.Equal("Chris", second.Speaker));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
transcriptStore.ReplacedSegments,
|
transcriptStore.ReplacedSegments,
|
||||||
segment => segment.Text.Contains("first", StringComparison.Ordinal) && segment.Speaker == "Chris");
|
segment => segment.Text?.Contains("first", StringComparison.Ordinal) == true && segment.Speaker == "Chris");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -714,13 +865,14 @@ public sealed class RecordingCoordinatorTests
|
|||||||
{
|
{
|
||||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||||
var transcriptStore = new InMemoryTranscriptStore();
|
var transcriptStore = new InMemoryTranscriptStore();
|
||||||
|
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||||
var coordinator = new MeetingRecordingCoordinator(
|
var coordinator = new MeetingRecordingCoordinator(
|
||||||
audioSource,
|
audioSource,
|
||||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||||
transcriptStore,
|
transcriptStore,
|
||||||
new InMemoryMeetingNoteStore(),
|
new InMemoryMeetingNoteStore(),
|
||||||
new CapturingMeetingNoteOpener(),
|
new CapturingMeetingNoteOpener(),
|
||||||
new InMemoryMeetingArtifactStore(),
|
artifactStore,
|
||||||
new InMemoryRecordedAudioStore(),
|
new InMemoryRecordedAudioStore(),
|
||||||
new CapturingMeetingSummaryPipeline(),
|
new CapturingMeetingSummaryPipeline(),
|
||||||
Options.Create(new MeetingAssistantOptions()),
|
Options.Create(new MeetingAssistantOptions()),
|
||||||
@@ -732,6 +884,7 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
|
Assert.NotNull(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime);
|
||||||
|
Assert.Equal(transcriptStore.MetadataMeetingNote?.Frontmatter.EndTime, artifactStore.ContextMeetingNote?.Frontmatter.EndTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -757,7 +910,7 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[AssistantContextState.Summarizing, AssistantContextState.Finished],
|
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||||
artifactStore.States);
|
artifactStore.States);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -784,7 +937,7 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[AssistantContextState.Summarizing, AssistantContextState.Error],
|
[AssistantContextState.Transcribing, AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||||
artifactStore.States);
|
artifactStore.States);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,7 +971,7 @@ public sealed class RecordingCoordinatorTests
|
|||||||
await coordinator.StopAsync(CancellationToken.None);
|
await coordinator.StopAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
[AssistantContextState.Transcribing, AssistantContextState.SpeakerRecognition, AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||||
artifactStore.States);
|
artifactStore.States);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -842,6 +995,23 @@ public sealed class RecordingCoordinatorTests
|
|||||||
Assert.True(Directory.Exists(vaultFolder));
|
Assert.True(Directory.Exists(vaultFolder));
|
||||||
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
|
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
|
||||||
Assert.Contains("hello vault", await File.ReadAllTextAsync(session.TranscriptPath));
|
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]
|
[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 sealed class InMemoryTranscriptStore : ITranscriptStore
|
||||||
{
|
{
|
||||||
private readonly List<TranscriptionSegment> segments = [];
|
private readonly List<TranscriptionSegment> segments = [];
|
||||||
@@ -931,7 +1124,7 @@ public sealed class RecordingCoordinatorTests
|
|||||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||||
while (DateTimeOffset.UtcNow < deadline)
|
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;
|
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 sealed class InMemoryMeetingNoteStore : IMeetingNoteStore
|
||||||
{
|
{
|
||||||
private readonly string notePath;
|
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
|
private sealed class CapturingMeetingNoteOpener : IMeetingNoteOpener
|
||||||
{
|
{
|
||||||
public string? OpenedPath { get; private set; }
|
public string? OpenedPath { get; private set; }
|
||||||
@@ -1027,13 +1319,17 @@ public sealed class RecordingCoordinatorTests
|
|||||||
|
|
||||||
public DateTimeOffset? ScheduledEnd { get; private set; }
|
public DateTimeOffset? ScheduledEnd { get; private set; }
|
||||||
|
|
||||||
|
public MeetingNote? ContextMeetingNote { get; private set; }
|
||||||
|
|
||||||
public Task CreateAssistantContextAsync(
|
public Task CreateAssistantContextAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
CreatedArtifacts = artifacts;
|
CreatedArtifacts = artifacts;
|
||||||
|
ContextMeetingNote = meetingNote;
|
||||||
Agenda = agenda;
|
Agenda = agenda;
|
||||||
ScheduledEnd = scheduledEnd;
|
ScheduledEnd = scheduledEnd;
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -1050,14 +1346,70 @@ public sealed class RecordingCoordinatorTests
|
|||||||
|
|
||||||
public Task UpdateAssistantContextMetadataAsync(
|
public Task UpdateAssistantContextMetadataAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ContextMeetingNote = meetingNote;
|
||||||
Agenda = agenda;
|
Agenda = agenda;
|
||||||
ScheduledEnd = scheduledEnd;
|
ScheduledEnd = scheduledEnd;
|
||||||
return Task.CompletedTask;
|
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
|
private sealed class FixedMeetingMetadataProvider : IMeetingMetadataProvider
|
||||||
@@ -1130,6 +1482,8 @@ public sealed class RecordingCoordinatorTests
|
|||||||
|
|
||||||
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
public MeetingSessionArtifacts? Artifacts { get; private set; }
|
||||||
|
|
||||||
|
public MeetingAssistantOptions? Options { get; private set; }
|
||||||
|
|
||||||
public Task<MeetingSummaryRunResult> RunAsync(
|
public Task<MeetingSummaryRunResult> RunAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -1141,6 +1495,192 @@ public sealed class RecordingCoordinatorTests
|
|||||||
succeeded,
|
succeeded,
|
||||||
succeeded ? null : "error"));
|
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
|
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 sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||||
{
|
{
|
||||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
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
|
private sealed class CapturingPipelineOptionsProvider : IStreamingTranscriptionProvider
|
||||||
{
|
{
|
||||||
public TaskCompletionSource OptionsObserved { get; } =
|
public TaskCompletionSource OptionsObserved { get; } =
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
aliases: ["M. Schweigert"],
|
aliases: ["M. Schweigert"],
|
||||||
snippets: [[1], [2], [3]],
|
snippets: [[1], [2], [3]],
|
||||||
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
|
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
|
||||||
transcriptionCount: 5);
|
referenceCount: 5);
|
||||||
var recent = await fixture.AddIdentityAsync(
|
var recent = await fixture.AddIdentityAsync(
|
||||||
canonicalName: "Guest Manuel",
|
canonicalName: "Guest Manuel",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
snippets: [[4], [5]],
|
snippets: [[4], [5]],
|
||||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
transcriptionCount: 2);
|
referenceCount: 2);
|
||||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
@@ -36,7 +36,9 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet);
|
Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet);
|
||||||
var savedOlder = await fixture.LoadIdentityAsync(older.Id);
|
var savedOlder = await fixture.LoadIdentityAsync(older.Id);
|
||||||
Assert.Equal("Manuel", savedOlder.CanonicalName);
|
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.True(savedOlder.UpdatedAt > older.UpdatedAt);
|
||||||
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
|
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
|
||||||
Assert.Equal(3, savedOlder.Snippets.Count);
|
Assert.Equal(3, savedOlder.Snippets.Count);
|
||||||
@@ -74,10 +76,12 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
|
|
||||||
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
|
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
private readonly string tempDirectory;
|
||||||
private readonly string dbPath;
|
private readonly string dbPath;
|
||||||
|
|
||||||
private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context)
|
private SpeakerIdentityMergeFixture(string tempDirectory, string dbPath, SpeakerIdentityDbContext context)
|
||||||
{
|
{
|
||||||
|
this.tempDirectory = tempDirectory;
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
Context = context;
|
Context = context;
|
||||||
}
|
}
|
||||||
@@ -88,13 +92,14 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
|
|
||||||
public static async Task<SpeakerIdentityMergeFixture> CreateAsync()
|
public static async Task<SpeakerIdentityMergeFixture> CreateAsync()
|
||||||
{
|
{
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
Directory.CreateDirectory(tempDirectory);
|
||||||
|
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||||
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||||
.Options);
|
.Options);
|
||||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||||
return new SpeakerIdentityMergeFixture(dbPath, context);
|
return new SpeakerIdentityMergeFixture(tempDirectory, dbPath, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SpeakerIdentityMergeService CreateService()
|
public SpeakerIdentityMergeService CreateService()
|
||||||
@@ -118,14 +123,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
IReadOnlyList<string> aliases,
|
IReadOnlyList<string> aliases,
|
||||||
IReadOnlyList<byte[]> snippets,
|
IReadOnlyList<byte[]> snippets,
|
||||||
DateTimeOffset createdAt,
|
DateTimeOffset createdAt,
|
||||||
int transcriptionCount)
|
int referenceCount)
|
||||||
{
|
{
|
||||||
var identity = new SpeakerIdentity
|
var identity = new SpeakerIdentity
|
||||||
{
|
{
|
||||||
CanonicalName = canonicalName,
|
CanonicalName = canonicalName,
|
||||||
CreatedAt = createdAt,
|
CreatedAt = createdAt,
|
||||||
UpdatedAt = createdAt,
|
UpdatedAt = createdAt,
|
||||||
TranscriptionCount = transcriptionCount,
|
|
||||||
Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(),
|
Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(),
|
||||||
Snippets = snippets
|
Snippets = snippets
|
||||||
.Select((snippet, index) => new SpeakerSnippet
|
.Select((snippet, index) => new SpeakerSnippet
|
||||||
@@ -133,6 +137,19 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
WavBytes = snippet,
|
WavBytes = snippet,
|
||||||
CreatedAt = createdAt.AddMinutes(index)
|
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()
|
.ToList()
|
||||||
};
|
};
|
||||||
Context.SpeakerIdentities.Add(identity);
|
Context.SpeakerIdentities.Add(identity);
|
||||||
@@ -146,15 +163,16 @@ public sealed class SpeakerIdentityMergeServiceTests
|
|||||||
return Context.SpeakerIdentities
|
return Context.SpeakerIdentities
|
||||||
.Include(identity => identity.Aliases)
|
.Include(identity => identity.Aliases)
|
||||||
.Include(identity => identity.Snippets)
|
.Include(identity => identity.Snippets)
|
||||||
|
.Include(identity => identity.References)
|
||||||
.SingleAsync(identity => identity.Id == id);
|
.SingleAsync(identity => identity.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await Context.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()
|
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
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;
|
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
@@ -27,7 +27,9 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||||
Assert.Equal("John", saved.CanonicalName);
|
Assert.Equal("John", saved.CanonicalName);
|
||||||
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
|
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.Segments.Single().Speaker);
|
||||||
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
|
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
[],
|
[],
|
||||||
[1, 2, 3],
|
[1, 2, 3],
|
||||||
"Chris",
|
"Chris",
|
||||||
transcriptionCount: 5,
|
referenceCount: 5,
|
||||||
updatedAt: oldTimestamp);
|
updatedAt: oldTimestamp);
|
||||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
@@ -54,6 +56,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
|
|
||||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||||
Assert.True(saved.UpdatedAt > oldTimestamp);
|
Assert.True(saved.UpdatedAt > oldTimestamp);
|
||||||
|
Assert.Equal(6, saved.References.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -73,6 +76,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||||
Assert.Null(saved.CanonicalName);
|
Assert.Null(saved.CanonicalName);
|
||||||
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||||
|
Assert.Single(saved.References);
|
||||||
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
|
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +97,28 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||||
Assert.Equal("Michael", saved.CanonicalName);
|
Assert.Equal("Michael", saved.CanonicalName);
|
||||||
Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name));
|
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]
|
[Fact]
|
||||||
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
|
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
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;
|
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
@@ -115,6 +134,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
|
|
||||||
var learned = await fixture.Context.SpeakerIdentities
|
var learned = await fixture.Context.SpeakerIdentities
|
||||||
.Include(identity => identity.CandidateNames)
|
.Include(identity => identity.CandidateNames)
|
||||||
|
.Include(identity => identity.References)
|
||||||
.Where(identity => identity.CanonicalName == null)
|
.Where(identity => identity.CanonicalName == null)
|
||||||
.OrderBy(identity => identity.Id)
|
.OrderBy(identity => identity.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -124,6 +144,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
{
|
{
|
||||||
Assert.NotEqual(default, identity.CreatedAt);
|
Assert.NotEqual(default, identity.CreatedAt);
|
||||||
Assert.NotEqual(default, identity.UpdatedAt);
|
Assert.NotEqual(default, identity.UpdatedAt);
|
||||||
|
Assert.Single(identity.References);
|
||||||
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
|
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,7 +153,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
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;
|
fixture.Matcher.MatchIdentityId = michael.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
@@ -147,16 +168,42 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
|
|
||||||
var learned = await fixture.Context.SpeakerIdentities
|
var learned = await fixture.Context.SpeakerIdentities
|
||||||
.Include(identity => identity.CandidateNames)
|
.Include(identity => identity.CandidateNames)
|
||||||
.Where(identity => identity.CanonicalName == null)
|
.Include(identity => identity.References)
|
||||||
|
.Where(identity => identity.CanonicalName == "Jane")
|
||||||
.SingleAsync();
|
.SingleAsync();
|
||||||
|
Assert.Equal("Jane", learned.CanonicalName);
|
||||||
Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name));
|
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]
|
[Fact]
|
||||||
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
|
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
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;
|
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest03", "hello from the live buffer");
|
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()
|
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
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;
|
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
@@ -190,7 +237,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||||
Assert.Null(saved.CanonicalName);
|
Assert.Null(saved.CanonicalName);
|
||||||
Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
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.Single(saved.Snippets);
|
||||||
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
||||||
Assert.Empty(result.SpeakerMappings);
|
Assert.Empty(result.SpeakerMappings);
|
||||||
@@ -200,9 +247,9 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
|
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
|
||||||
{
|
{
|
||||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", transcriptionCount: 99);
|
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", referenceCount: 99);
|
||||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", transcriptionCount: 1, aliases: ["Mike"]);
|
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", referenceCount: 1, aliases: ["Mike"]);
|
||||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", transcriptionCount: 2);
|
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", referenceCount: 2);
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
await service.IdentifyKnownSpeakersAsync(
|
await service.IdentifyKnownSpeakersAsync(
|
||||||
@@ -223,10 +270,10 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
options.MaxMatchCandidates = 2;
|
options.MaxMatchCandidates = 2;
|
||||||
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
|
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
|
||||||
});
|
});
|
||||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", transcriptionCount: 50);
|
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", referenceCount: 50);
|
||||||
await fixture.AddIdentityAsync([], [2], "Stale High", transcriptionCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
await fixture.AddIdentityAsync([], [2], "Stale High", referenceCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", transcriptionCount: 5);
|
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", referenceCount: 5);
|
||||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", transcriptionCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", referenceCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||||
var service = fixture.CreateService();
|
var service = fixture.CreateService();
|
||||||
|
|
||||||
await service.IdentifyKnownSpeakersAsync(
|
await service.IdentifyKnownSpeakersAsync(
|
||||||
@@ -240,6 +287,68 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
Assert.DoesNotContain(activeLow.Id, requestedIds);
|
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]
|
[Fact]
|
||||||
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
|
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
|
||||||
{
|
{
|
||||||
@@ -275,14 +384,17 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
|
|
||||||
private sealed class SpeakerIdentityFixture : IAsyncDisposable
|
private sealed class SpeakerIdentityFixture : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
private readonly string tempDirectory;
|
||||||
private readonly string dbPath;
|
private readonly string dbPath;
|
||||||
private readonly SpeakerIdentificationOptions options;
|
private readonly SpeakerIdentificationOptions options;
|
||||||
|
|
||||||
private SpeakerIdentityFixture(
|
private SpeakerIdentityFixture(
|
||||||
|
string tempDirectory,
|
||||||
string dbPath,
|
string dbPath,
|
||||||
SpeakerIdentityDbContext context,
|
SpeakerIdentityDbContext context,
|
||||||
SpeakerIdentificationOptions options)
|
SpeakerIdentificationOptions options)
|
||||||
{
|
{
|
||||||
|
this.tempDirectory = tempDirectory;
|
||||||
this.dbPath = dbPath;
|
this.dbPath = dbPath;
|
||||||
Context = context;
|
Context = context;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
@@ -297,8 +409,9 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
public static async Task<SpeakerIdentityFixture> CreateAsync(
|
public static async Task<SpeakerIdentityFixture> CreateAsync(
|
||||||
Action<SpeakerIdentificationOptions>? configureOptions = null)
|
Action<SpeakerIdentificationOptions>? configureOptions = null)
|
||||||
{
|
{
|
||||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
Directory.CreateDirectory(tempDirectory);
|
||||||
|
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||||
var options = new SpeakerIdentificationOptions
|
var options = new SpeakerIdentificationOptions
|
||||||
{
|
{
|
||||||
DatabasePath = dbPath,
|
DatabasePath = dbPath,
|
||||||
@@ -310,7 +423,7 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||||
.Options);
|
.Options);
|
||||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||||
return new SpeakerIdentityFixture(dbPath, context, options);
|
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SpeakerIdentityService CreateService()
|
public SpeakerIdentityService CreateService()
|
||||||
@@ -323,29 +436,44 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
NullLogger<SpeakerIdentityService>.Instance);
|
NullLogger<SpeakerIdentityService>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
|
||||||
|
{
|
||||||
|
return new SpeakerIdentityAttendeeCanonicalizer(new TestSpeakerIdentityDbContextFactory(dbPath));
|
||||||
|
}
|
||||||
|
|
||||||
public SpeakerIdentificationRequest CreateRequest(
|
public SpeakerIdentificationRequest CreateRequest(
|
||||||
IReadOnlyList<string> attendees,
|
IReadOnlyList<string> attendees,
|
||||||
IReadOnlyList<TranscriptionSegment> segments,
|
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(
|
return new SpeakerIdentificationRequest(
|
||||||
"test.wav",
|
"test.wav",
|
||||||
MeetingNoteTemplate.Create(
|
new MeetingNote(
|
||||||
"Test Meeting",
|
meetingNotePath,
|
||||||
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
new MeetingNoteFrontmatter
|
||||||
attendees: attendees,
|
{
|
||||||
transcriptPath: "transcript.md",
|
Title = "Test Meeting",
|
||||||
assistantContextPath: "context.md",
|
StartTime = DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||||
summaryPath: "summary.md"),
|
Attendees = attendees.ToList(),
|
||||||
|
Transcript = transcriptPath,
|
||||||
|
AssistantContext = Path.Combine(tempDirectory, "context.md"),
|
||||||
|
Summary = Path.Combine(tempDirectory, "summary.md")
|
||||||
|
},
|
||||||
|
""),
|
||||||
segments,
|
segments,
|
||||||
samples);
|
samples,
|
||||||
|
knownSpeakerMappings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||||
IReadOnlyList<string> candidates,
|
IReadOnlyList<string> candidates,
|
||||||
byte[] snippet,
|
byte[] snippet,
|
||||||
string? canonicalName = null,
|
string? canonicalName = null,
|
||||||
int transcriptionCount = 0,
|
int referenceCount = 0,
|
||||||
IReadOnlyList<string>? aliases = null,
|
IReadOnlyList<string>? aliases = null,
|
||||||
DateTimeOffset? updatedAt = null)
|
DateTimeOffset? updatedAt = null)
|
||||||
{
|
{
|
||||||
@@ -353,7 +481,6 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
var identity = new SpeakerIdentity
|
var identity = new SpeakerIdentity
|
||||||
{
|
{
|
||||||
CanonicalName = canonicalName,
|
CanonicalName = canonicalName,
|
||||||
TranscriptionCount = transcriptionCount,
|
|
||||||
CreatedAt = timestamp,
|
CreatedAt = timestamp,
|
||||||
UpdatedAt = timestamp,
|
UpdatedAt = timestamp,
|
||||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||||
@@ -365,7 +492,20 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
WavBytes = snippet,
|
WavBytes = snippet,
|
||||||
CreatedAt = timestamp.AddMinutes(-10)
|
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);
|
Context.SpeakerIdentities.Add(identity);
|
||||||
await Context.SaveChangesAsync();
|
await Context.SaveChangesAsync();
|
||||||
@@ -379,15 +519,16 @@ public sealed class SpeakerIdentityServiceTests
|
|||||||
.Include(identity => identity.CandidateNames)
|
.Include(identity => identity.CandidateNames)
|
||||||
.Include(identity => identity.Aliases)
|
.Include(identity => identity.Aliases)
|
||||||
.Include(identity => identity.Snippets)
|
.Include(identity => identity.Snippets)
|
||||||
|
.Include(identity => identity.References)
|
||||||
.SingleAsync(identity => identity.Id == id);
|
.SingleAsync(identity => identity.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await Context.DisposeAsync();
|
await Context.DisposeAsync();
|
||||||
if (File.Exists(dbPath))
|
if (Directory.Exists(tempDirectory))
|
||||||
{
|
{
|
||||||
File.Delete(dbPath);
|
Directory.Delete(tempDirectory, recursive: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.Recording;
|
using MeetingAssistant.Recording;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace MeetingAssistant.Hotkeys;
|
namespace MeetingAssistant.Hotkeys;
|
||||||
|
|
||||||
public sealed class GlobalHotkeyService : BackgroundService
|
public sealed class GlobalHotkeyService : BackgroundService
|
||||||
{
|
{
|
||||||
private const int HotkeyId = 0x4D41;
|
private const int HotkeyIdBase = 0x4D41;
|
||||||
private const int WmHotkey = 0x0312;
|
private const int WmHotkey = 0x0312;
|
||||||
private const int WmQuit = 0x0012;
|
private const int WmQuit = 0x0012;
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
||||||
private readonly MeetingRecordingCoordinator coordinator;
|
private readonly MeetingRecordingCoordinator coordinator;
|
||||||
private readonly ILogger<GlobalHotkeyService> logger;
|
private readonly ILogger<GlobalHotkeyService> logger;
|
||||||
|
private readonly Dictionary<int, string> hotkeyProfiles = [];
|
||||||
private TaskCompletionSource? messageLoopCompletion;
|
private TaskCompletionSource? messageLoopCompletion;
|
||||||
private uint messageThreadId;
|
private uint messageThreadId;
|
||||||
|
|
||||||
public GlobalHotkeyService(
|
public GlobalHotkeyService(
|
||||||
IOptions<MeetingAssistantOptions> options,
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
MeetingRecordingCoordinator coordinator,
|
MeetingRecordingCoordinator coordinator,
|
||||||
ILogger<GlobalHotkeyService> logger)
|
ILogger<GlobalHotkeyService> logger)
|
||||||
{
|
{
|
||||||
this.options = options.Value;
|
this.launchProfiles = launchProfiles;
|
||||||
this.coordinator = coordinator;
|
this.coordinator = coordinator;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
@@ -58,31 +59,49 @@ public sealed class GlobalHotkeyService : BackgroundService
|
|||||||
|
|
||||||
private void RunMessageLoop(CancellationToken stoppingToken)
|
private void RunMessageLoop(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
var hotkey = HotkeyDefinition.Parse(options.Hotkey.Toggle);
|
var hotkeys = launchProfiles.GetHotkeys();
|
||||||
messageThreadId = GetCurrentThreadId();
|
messageThreadId = GetCurrentThreadId();
|
||||||
using var stoppingRegistration = stoppingToken.Register(() => PostQuitToMessageThread());
|
using var stoppingRegistration = stoppingToken.Register(() => PostQuitToMessageThread());
|
||||||
|
|
||||||
if (!RegisterHotKey(IntPtr.Zero, HotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey))
|
for (var index = 0; index < hotkeys.Count; index++)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Could not register global hotkey {Hotkey}", options.Hotkey.Toggle);
|
var profileHotkey = hotkeys[index];
|
||||||
return;
|
var hotkey = HotkeyDefinition.Parse(profileHotkey.Toggle);
|
||||||
}
|
var hotkeyId = HotkeyIdBase + index;
|
||||||
|
if (!RegisterHotKey(IntPtr.Zero, hotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Could not register global hotkey {Hotkey} for launch profile {LaunchProfile}",
|
||||||
|
profileHotkey.Toggle,
|
||||||
|
profileHotkey.ProfileName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation("Registered global hotkey {Hotkey}", options.Hotkey.Toggle);
|
hotkeyProfiles[hotkeyId] = profileHotkey.ProfileName;
|
||||||
|
logger.LogInformation(
|
||||||
|
"Registered global hotkey {Hotkey} for launch profile {LaunchProfile}",
|
||||||
|
profileHotkey.Toggle,
|
||||||
|
profileHotkey.ProfileName);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
||||||
{
|
{
|
||||||
if (message.Message == WmHotkey)
|
if (message.Message == WmHotkey && hotkeyProfiles.TryGetValue((int)message.WParam, out var profileName))
|
||||||
{
|
{
|
||||||
_ = Task.Run(ToggleRecordingAsync, CancellationToken.None);
|
_ = Task.Run(() => ToggleRecordingAsync(profileName), CancellationToken.None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
UnregisterHotKey(IntPtr.Zero, HotkeyId);
|
foreach (var hotkeyId in hotkeyProfiles.Keys)
|
||||||
|
{
|
||||||
|
UnregisterHotKey(IntPtr.Zero, hotkeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
hotkeyProfiles.Clear();
|
||||||
messageThreadId = 0;
|
messageThreadId = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,12 +112,15 @@ public sealed class GlobalHotkeyService : BackgroundService
|
|||||||
return base.StopAsync(cancellationToken);
|
return base.StopAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleRecordingAsync()
|
private async Task ToggleRecordingAsync(string profileName)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var status = await coordinator.ToggleAsync(CancellationToken.None);
|
var status = await coordinator.ToggleAsync(profileName, CancellationToken.None);
|
||||||
logger.LogInformation("Hotkey toggled recording. IsRecording={IsRecording}", status.IsRecording);
|
logger.LogInformation(
|
||||||
|
"Hotkey toggled recording for launch profile {LaunchProfile}. IsRecording={IsRecording}",
|
||||||
|
profileName,
|
||||||
|
status.IsRecording);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.LaunchProfiles;
|
||||||
|
|
||||||
|
public interface ILaunchProfileOptionsProvider
|
||||||
|
{
|
||||||
|
LaunchProfile GetRequiredProfile(string? name);
|
||||||
|
|
||||||
|
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record LaunchProfile(string Name, MeetingAssistantOptions Options);
|
||||||
|
|
||||||
|
public sealed record LaunchProfileHotkey(string ProfileName, string Toggle);
|
||||||
|
|
||||||
|
public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
|
||||||
|
{
|
||||||
|
public const string DefaultProfileName = "default";
|
||||||
|
|
||||||
|
private readonly IConfiguration configuration;
|
||||||
|
|
||||||
|
public ConfigurationLaunchProfileOptionsProvider(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
this.configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LaunchProfile GetRequiredProfile(string? name)
|
||||||
|
{
|
||||||
|
var profileName = NormalizeProfileName(name);
|
||||||
|
var options = BindDefaultOptions();
|
||||||
|
if (profileName.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new LaunchProfile(DefaultProfileName, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
var profileSection = GetProfilesSection().GetSection(profileName);
|
||||||
|
if (!profileSection.Exists())
|
||||||
|
{
|
||||||
|
throw new KeyNotFoundException($"Launch profile '{profileName}' is not configured.");
|
||||||
|
}
|
||||||
|
|
||||||
|
profileSection.Bind(options);
|
||||||
|
ApplyArrayOverrides(profileSection, options);
|
||||||
|
return new LaunchProfile(profileName, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
|
||||||
|
{
|
||||||
|
var hotkeys = GetProfileNames()
|
||||||
|
.Select(profileName =>
|
||||||
|
{
|
||||||
|
var profile = GetRequiredProfile(profileName);
|
||||||
|
return new LaunchProfileHotkey(profile.Name, profile.Options.Hotkey.Toggle);
|
||||||
|
})
|
||||||
|
.Where(hotkey => !string.IsNullOrWhiteSpace(hotkey.Toggle))
|
||||||
|
.ToList();
|
||||||
|
var duplicate = hotkeys
|
||||||
|
.GroupBy(hotkey => hotkey.Toggle.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(group => group.Count() > 1);
|
||||||
|
if (duplicate is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Launch profile hotkey '{duplicate.Key}' is configured more than once for profiles {string.Join(", ", duplicate.Select(hotkey => hotkey.ProfileName))}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return hotkeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> GetProfileNames()
|
||||||
|
{
|
||||||
|
return new[] { DefaultProfileName }
|
||||||
|
.Concat(GetProfilesSection()
|
||||||
|
.GetChildren()
|
||||||
|
.Select(section => section.Key)
|
||||||
|
.Where(key => !key.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingAssistantOptions BindDefaultOptions()
|
||||||
|
{
|
||||||
|
var options = new MeetingAssistantOptions();
|
||||||
|
GetRootSection().Bind(options);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IConfigurationSection GetRootSection()
|
||||||
|
{
|
||||||
|
return configuration.GetSection("MeetingAssistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
private IConfigurationSection GetProfilesSection()
|
||||||
|
{
|
||||||
|
return GetRootSection().GetSection("LaunchProfiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeProfileName(string? name)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(name) ||
|
||||||
|
name.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? DefaultProfileName
|
||||||
|
: name.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplyArrayOverrides(
|
||||||
|
IConfigurationSection profileSection,
|
||||||
|
MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
var autoDetectLanguages = ReadArrayOverride(profileSection.GetSection("AzureSpeech:AutoDetectLanguages"));
|
||||||
|
if (autoDetectLanguages is not null)
|
||||||
|
{
|
||||||
|
options.AzureSpeech.AutoDetectLanguages = autoDetectLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
var funAsrChunkSize = ReadIntArrayOverride(profileSection.GetSection("FunAsr:ChunkSize"));
|
||||||
|
if (funAsrChunkSize is not null)
|
||||||
|
{
|
||||||
|
options.FunAsr.ChunkSize = funAsrChunkSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string[]? ReadArrayOverride(IConfigurationSection section)
|
||||||
|
{
|
||||||
|
if (!section.Exists())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
.GetChildren()
|
||||||
|
.OrderBy(child => int.TryParse(child.Key, out var index) ? index : int.MaxValue)
|
||||||
|
.Select(child => child.Value)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Select(value => value!)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[]? ReadIntArrayOverride(IConfigurationSection section)
|
||||||
|
{
|
||||||
|
if (!section.Exists())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return section
|
||||||
|
.GetChildren()
|
||||||
|
.OrderBy(child => int.TryParse(child.Key, out var index) ? index : int.MaxValue)
|
||||||
|
.Select(child => int.TryParse(child.Value, out var value) ? value : (int?)null)
|
||||||
|
.Where(value => value.HasValue)
|
||||||
|
.Select(value => value!.Value)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.2" />
|
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.2" />
|
||||||
|
<PackageReference Include="DiffPlex" Version="1.9.0" />
|
||||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
|
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class AzureSpeechOptions
|
|||||||
{
|
{
|
||||||
public string Endpoint { get; set; } = "";
|
public string Endpoint { get; set; } = "";
|
||||||
|
|
||||||
public string Region { get; set; } = "germanywestcentral";
|
public string Region { get; set; } = "westeurope";
|
||||||
|
|
||||||
public string Language { get; set; } = "auto";
|
public string Language { get; set; } = "auto";
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ public sealed class AzureSpeechOptions
|
|||||||
|
|
||||||
public bool DiarizeIntermediateResults { get; set; } = true;
|
public bool DiarizeIntermediateResults { get; set; } = true;
|
||||||
|
|
||||||
|
public string? PostProcessingOption { get; set; }
|
||||||
|
|
||||||
public double PhraseListWeight { get; set; } = 1.5;
|
public double PhraseListWeight { get; set; } = 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +234,8 @@ public sealed class SpeakerIdentificationOptions
|
|||||||
|
|
||||||
public TimeSpan MergeRecentIdentityAge { get; set; } = TimeSpan.FromDays(14);
|
public TimeSpan MergeRecentIdentityAge { get; set; } = TimeSpan.FromDays(14);
|
||||||
|
|
||||||
|
public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3);
|
||||||
|
|
||||||
public AzureSpeechOptions AzureSpeech { get; set; } = new();
|
public AzureSpeechOptions AzureSpeech { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ public interface IMeetingArtifactStore
|
|||||||
{
|
{
|
||||||
Task CreateAssistantContextAsync(
|
Task CreateAssistantContextAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAssistantContextMetadataAsync(
|
Task UpdateAssistantContextMetadataAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task UpdateAssistantContextMeetingAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UpdateAssistantContextStateAsync(
|
Task UpdateAssistantContextStateAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
AssistantContextState state,
|
AssistantContextState state,
|
||||||
@@ -22,6 +29,7 @@ public interface IMeetingArtifactStore
|
|||||||
|
|
||||||
public enum AssistantContextState
|
public enum AssistantContextState
|
||||||
{
|
{
|
||||||
|
CollectingMetadata,
|
||||||
Transcribing,
|
Transcribing,
|
||||||
SpeakerRecognition,
|
SpeakerRecognition,
|
||||||
Summarizing,
|
Summarizing,
|
||||||
|
|||||||
@@ -4,5 +4,13 @@ public interface IMeetingNoteStore
|
|||||||
{
|
{
|
||||||
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
|
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<MeetingNote> SaveAsync(
|
||||||
|
MeetingNote note,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return SaveAsync(note, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
|
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
|
|
||||||
public async Task CreateAssistantContextAsync(
|
public async Task CreateAssistantContextAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -25,7 +26,8 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
|
|
||||||
var content = Render(
|
var content = Render(
|
||||||
artifacts,
|
artifacts,
|
||||||
AssistantContextState.Transcribing,
|
meetingNote,
|
||||||
|
AssistantContextState.CollectingMetadata,
|
||||||
agenda,
|
agenda,
|
||||||
scheduledEnd,
|
scheduledEnd,
|
||||||
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
|
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
|
||||||
@@ -45,12 +47,13 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
|
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
|
||||||
var body = existing.Body;
|
var body = existing.Body;
|
||||||
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
||||||
|
var metadata = existingFrontmatter.ToMeetingArtifactMetadata();
|
||||||
var agenda = existingFrontmatter.Agenda ?? "";
|
var agenda = existingFrontmatter.Agenda ?? "";
|
||||||
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
||||||
|
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
artifacts.AssistantContextPath,
|
artifacts.AssistantContextPath,
|
||||||
Render(artifacts, state, agenda, scheduledEnd, body),
|
Render(artifacts, metadata, state, agenda, scheduledEnd, body),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Updated assistant context note {AssistantContextPath} state to {State}",
|
"Updated assistant context note {AssistantContextPath} state to {State}",
|
||||||
@@ -60,6 +63,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
|
|
||||||
public async Task UpdateAssistantContextMetadataAsync(
|
public async Task UpdateAssistantContextMetadataAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
string agenda,
|
string agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -73,29 +77,68 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
|
|
||||||
await File.WriteAllTextAsync(
|
await File.WriteAllTextAsync(
|
||||||
artifacts.AssistantContextPath,
|
artifacts.AssistantContextPath,
|
||||||
Render(artifacts, state, agenda, scheduledEnd, existing.Body),
|
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Updated assistant context note {AssistantContextPath} calendar metadata",
|
"Updated assistant context note {AssistantContextPath} calendar metadata",
|
||||||
artifacts.AssistantContextPath);
|
artifacts.AssistantContextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAssistantContextMeetingAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||||
|
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||||
|
? MarkdownDocumentParser.SplitOptional(await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken))
|
||||||
|
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
|
||||||
|
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
||||||
|
var state = ParseState(existingFrontmatter.State);
|
||||||
|
var agenda = existingFrontmatter.Agenda ?? "";
|
||||||
|
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
artifacts.AssistantContextPath,
|
||||||
|
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
|
||||||
|
cancellationToken);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Updated assistant context note {AssistantContextPath} meeting metadata",
|
||||||
|
artifacts.AssistantContextPath);
|
||||||
|
}
|
||||||
|
|
||||||
private static string Render(
|
private static string Render(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingNote meetingNote,
|
||||||
AssistantContextState state,
|
AssistantContextState state,
|
||||||
string? agenda,
|
string? agenda,
|
||||||
DateTimeOffset? scheduledEnd,
|
DateTimeOffset? scheduledEnd,
|
||||||
string body)
|
string body)
|
||||||
{
|
{
|
||||||
var frontmatter = new MeetingArtifactFrontmatter
|
var metadata = new AssistantContextMeetingMetadata(
|
||||||
{
|
meetingNote.Frontmatter.Title,
|
||||||
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, artifacts.AssistantContextPath, "Meeting Note"),
|
meetingNote.Frontmatter.StartTime,
|
||||||
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, artifacts.AssistantContextPath, "Transcript"),
|
meetingNote.Frontmatter.EndTime);
|
||||||
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, artifacts.AssistantContextPath, "Summary"),
|
return Render(artifacts, metadata, state, agenda, scheduledEnd, body);
|
||||||
State = ToYamlState(state),
|
}
|
||||||
Agenda = agenda ?? "",
|
|
||||||
ScheduledEnd = scheduledEnd
|
private static string Render(
|
||||||
};
|
MeetingSessionArtifacts artifacts,
|
||||||
|
AssistantContextMeetingMetadata metadata,
|
||||||
|
AssistantContextState state,
|
||||||
|
string? agenda,
|
||||||
|
DateTimeOffset? scheduledEnd,
|
||||||
|
string body)
|
||||||
|
{
|
||||||
|
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||||
|
artifacts,
|
||||||
|
metadata.Title,
|
||||||
|
metadata.StartTime,
|
||||||
|
metadata.EndTime,
|
||||||
|
artifacts.AssistantContextPath,
|
||||||
|
ToYamlState(state));
|
||||||
|
frontmatter.Agenda = agenda ?? "";
|
||||||
|
frontmatter.ScheduledEnd = scheduledEnd;
|
||||||
|
|
||||||
return MeetingArtifactFrontmatterRenderer.Render(
|
return MeetingArtifactFrontmatterRenderer.Render(
|
||||||
frontmatter,
|
frontmatter,
|
||||||
@@ -125,23 +168,46 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
[YamlMember(Alias = "state")]
|
[YamlMember(Alias = "state")]
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
[YamlMember(Alias = "title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[YamlMember(Alias = "start_time")]
|
||||||
|
public string? StartTime { get; set; }
|
||||||
|
|
||||||
|
[YamlMember(Alias = "end_time")]
|
||||||
|
public string? EndTime { get; set; }
|
||||||
|
|
||||||
[YamlMember(Alias = "agenda")]
|
[YamlMember(Alias = "agenda")]
|
||||||
public string? Agenda { get; set; }
|
public string? Agenda { get; set; }
|
||||||
|
|
||||||
[YamlMember(Alias = "scheduled_end")]
|
[YamlMember(Alias = "scheduled_end")]
|
||||||
public string? ScheduledEnd { get; set; }
|
public string? ScheduledEnd { get; set; }
|
||||||
|
|
||||||
|
public AssistantContextMeetingMetadata ToMeetingArtifactMetadata()
|
||||||
|
{
|
||||||
|
return new AssistantContextMeetingMetadata(
|
||||||
|
Title ?? "",
|
||||||
|
ParseDateTime(StartTime),
|
||||||
|
ParseDateTime(EndTime));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record AssistantContextMeetingMetadata(
|
||||||
|
string Title,
|
||||||
|
DateTimeOffset? StartTime,
|
||||||
|
DateTimeOffset? EndTime);
|
||||||
|
|
||||||
private static AssistantContextState ParseState(string? value)
|
private static AssistantContextState ParseState(string? value)
|
||||||
{
|
{
|
||||||
return value?.Trim().ToLowerInvariant() switch
|
return value?.Trim().ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"transcribing" => AssistantContextState.Transcribing,
|
"transcribing" => AssistantContextState.Transcribing,
|
||||||
|
"collecting metadata" => AssistantContextState.CollectingMetadata,
|
||||||
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
||||||
"summarizing" => AssistantContextState.Summarizing,
|
"summarizing" => AssistantContextState.Summarizing,
|
||||||
"finished" => AssistantContextState.Finished,
|
"finished" => AssistantContextState.Finished,
|
||||||
"error" => AssistantContextState.Error,
|
"error" => AssistantContextState.Error,
|
||||||
_ => AssistantContextState.Transcribing
|
_ => AssistantContextState.CollectingMetadata
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +215,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
|||||||
{
|
{
|
||||||
return state switch
|
return state switch
|
||||||
{
|
{
|
||||||
|
AssistantContextState.CollectingMetadata => "collecting metadata",
|
||||||
AssistantContextState.Transcribing => "transcribing",
|
AssistantContextState.Transcribing => "transcribing",
|
||||||
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
||||||
AssistantContextState.Summarizing => "summarizing",
|
AssistantContextState.Summarizing => "summarizing",
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SaveAsync(note, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MeetingNote> SaveAsync(
|
||||||
|
MeetingNote note,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var path = string.IsNullOrWhiteSpace(note.Path)
|
var path = string.IsNullOrWhiteSpace(note.Path)
|
||||||
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Slugify(note.Frontmatter.Title)}.md")
|
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md")
|
||||||
: note.Path;
|
: note.Path;
|
||||||
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
|
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
|
||||||
var content = Render(frontmatter, note.UserNotes);
|
var content = Render(frontmatter, note.UserNotes);
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ public static class MeetingArtifactFrontmatterRenderer
|
|||||||
AppendScalar(builder, "title", frontmatter.Title);
|
AppendScalar(builder, "title", frontmatter.Title);
|
||||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||||
AppendQuoted(builder, "meeting", frontmatter.Meeting);
|
AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting);
|
||||||
AppendQuoted(builder, "transcript", frontmatter.Transcript);
|
AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript);
|
||||||
AppendQuoted(builder, "assistant_context", frontmatter.AssistantContext);
|
AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext);
|
||||||
AppendQuoted(builder, "summary", frontmatter.Summary);
|
AppendQuotedIfNotEmpty(builder, "summary", frontmatter.Summary);
|
||||||
if (!string.IsNullOrWhiteSpace(frontmatter.State))
|
if (!string.IsNullOrWhiteSpace(frontmatter.State))
|
||||||
{
|
{
|
||||||
AppendScalar(builder, "state", frontmatter.State);
|
AppendScalar(builder, "state", frontmatter.State);
|
||||||
@@ -73,16 +73,33 @@ public static class MeetingArtifactFrontmatterRenderer
|
|||||||
string title,
|
string title,
|
||||||
string sourceNotePath,
|
string sourceNotePath,
|
||||||
string? state = null)
|
string? state = null)
|
||||||
|
{
|
||||||
|
return Create(
|
||||||
|
artifacts,
|
||||||
|
title,
|
||||||
|
meetingNote.Frontmatter.StartTime,
|
||||||
|
meetingNote.Frontmatter.EndTime,
|
||||||
|
sourceNotePath,
|
||||||
|
state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MeetingArtifactFrontmatter Create(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
string title,
|
||||||
|
DateTimeOffset? startTime,
|
||||||
|
DateTimeOffset? endTime,
|
||||||
|
string sourceNotePath,
|
||||||
|
string? state = null)
|
||||||
{
|
{
|
||||||
return new MeetingArtifactFrontmatter
|
return new MeetingArtifactFrontmatter
|
||||||
{
|
{
|
||||||
Title = title,
|
Title = title,
|
||||||
StartTime = meetingNote.Frontmatter.StartTime,
|
StartTime = startTime,
|
||||||
EndTime = meetingNote.Frontmatter.EndTime,
|
EndTime = endTime,
|
||||||
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
|
Meeting = LinkUnlessSelf(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
|
||||||
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
|
Transcript = LinkUnlessSelf(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
|
||||||
AssistantContext = ObsidianLink.ToWikiLink(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
|
AssistantContext = LinkUnlessSelf(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
|
||||||
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, sourceNotePath, "Summary"),
|
Summary = LinkUnlessSelf(artifacts.SummaryPath, sourceNotePath, "Summary"),
|
||||||
State = state
|
State = state
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -108,6 +125,16 @@ public static class MeetingArtifactFrontmatterRenderer
|
|||||||
builder.AppendLine(EscapeQuoted(value));
|
builder.AppendLine(EscapeQuoted(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AppendQuotedIfNotEmpty(StringBuilder builder, string key, string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppendQuoted(builder, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
|
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
|
||||||
{
|
{
|
||||||
builder.Append(key);
|
builder.Append(key);
|
||||||
@@ -145,4 +172,19 @@ public static class MeetingArtifactFrontmatterRenderer
|
|||||||
: value;
|
: value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string LinkUnlessSelf(string targetPath, string sourceNotePath, string label)
|
||||||
|
{
|
||||||
|
return IsSamePath(targetPath, sourceNotePath)
|
||||||
|
? ""
|
||||||
|
: ObsidianLink.ToWikiLink(targetPath, sourceNotePath, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSamePath(string first, string second)
|
||||||
|
{
|
||||||
|
return string.Equals(
|
||||||
|
Path.GetFullPath(first),
|
||||||
|
Path.GetFullPath(second),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MeetingAssistant.MeetingNotes;
|
||||||
|
|
||||||
|
internal static class MeetingAttendeeNames
|
||||||
|
{
|
||||||
|
public static string NormalizeDisplayName(string attendee)
|
||||||
|
{
|
||||||
|
var trimmed = attendee.Trim();
|
||||||
|
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||||
|
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
-26
@@ -1,5 +1,6 @@
|
|||||||
using MeetingAssistant;
|
using MeetingAssistant;
|
||||||
using MeetingAssistant.Hotkeys;
|
using MeetingAssistant.Hotkeys;
|
||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Recording;
|
using MeetingAssistant.Recording;
|
||||||
using MeetingAssistant.Speakers;
|
using MeetingAssistant.Speakers;
|
||||||
@@ -10,6 +11,7 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
|
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
|
||||||
|
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||||
builder.Services.AddSingleton<SystemAudioSource>();
|
builder.Services.AddSingleton<SystemAudioSource>();
|
||||||
@@ -40,6 +42,7 @@ builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpea
|
|||||||
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
|
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
|
||||||
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
|
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
|
||||||
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
|
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
|
||||||
|
builder.Services.AddSingleton<ISpeakerIdentityAttendeeCanonicalizer, SpeakerIdentityAttendeeCanonicalizer>();
|
||||||
builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArtifactResolver>();
|
builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArtifactResolver>();
|
||||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||||
@@ -54,6 +57,9 @@ builder.Services.AddTransient<WhisperLocalStreamingTranscriptionProvider>();
|
|||||||
builder.Services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
builder.Services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||||
builder.Services.AddTransient<FunAsrTranscriptFinalizer>();
|
builder.Services.AddTransient<FunAsrTranscriptFinalizer>();
|
||||||
builder.Services.AddTransient<PyannoteTranscriptFinalizer>();
|
builder.Services.AddTransient<PyannoteTranscriptFinalizer>();
|
||||||
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, AzureSpeechRecognitionPipelineBuilder>();
|
||||||
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, FunAsrSpeechRecognitionPipelineBuilder>();
|
||||||
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
|
||||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
|
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
|
||||||
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
|
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
|
||||||
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
||||||
@@ -72,17 +78,54 @@ app.MapGet("/health", () => Results.Ok(new
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||||
|
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
||||||
|
string launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||||
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||||
IMeetingMetadataProvider metadataProvider,
|
IMeetingMetadataProvider metadataProvider,
|
||||||
IOptions<MeetingAssistantOptions> options,
|
IOptions<MeetingAssistantOptions> options,
|
||||||
DateTimeOffset? startedAt,
|
DateTimeOffset? startedAt,
|
||||||
double? timeoutSeconds,
|
double? timeoutSeconds,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await DiagnoseOutlookMeetingAsync(
|
||||||
|
null,
|
||||||
|
metadataProvider,
|
||||||
|
options.Value,
|
||||||
|
null,
|
||||||
|
startedAt,
|
||||||
|
timeoutSeconds,
|
||||||
|
cancellationToken));
|
||||||
|
app.MapGet("/profiles/{launchProfile}/diagnostics/outlook/current-meeting", async (
|
||||||
|
string launchProfile,
|
||||||
|
IMeetingMetadataProvider metadataProvider,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
|
DateTimeOffset? startedAt,
|
||||||
|
double? timeoutSeconds,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await DiagnoseOutlookMeetingAsync(
|
||||||
|
launchProfile,
|
||||||
|
metadataProvider,
|
||||||
|
options.Value,
|
||||||
|
launchProfiles,
|
||||||
|
startedAt,
|
||||||
|
timeoutSeconds,
|
||||||
|
cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> DiagnoseOutlookMeetingAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
IMeetingMetadataProvider metadataProvider,
|
||||||
|
MeetingAssistantOptions defaultOptions,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles,
|
||||||
|
DateTimeOffset? startedAt,
|
||||||
|
double? timeoutSeconds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||||
var lookupStartedAt = startedAt ?? DateTimeOffset.Now;
|
var lookupStartedAt = startedAt ?? DateTimeOffset.Now;
|
||||||
var timeout = timeoutSeconds is > 0
|
var timeout = timeoutSeconds is > 0
|
||||||
? TimeSpan.FromSeconds(timeoutSeconds.Value)
|
? TimeSpan.FromSeconds(timeoutSeconds.Value)
|
||||||
: options.Value.Recording.MetadataLookupTimeout;
|
: profileOptions.Recording.MetadataLookupTimeout;
|
||||||
if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
|
if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
|
||||||
{
|
{
|
||||||
return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync(
|
return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync(
|
||||||
@@ -102,78 +145,224 @@ app.MapGet("/diagnostics/outlook/current-meeting", async (
|
|||||||
stopwatch.ElapsedMilliseconds,
|
stopwatch.ElapsedMilliseconds,
|
||||||
metadata,
|
metadata,
|
||||||
metadata is null ? "No meeting metadata provider result was available." : null));
|
metadata is null ? "No meeting metadata provider result was available." : null));
|
||||||
});
|
}
|
||||||
|
|
||||||
|
static MeetingAssistantOptions ResolveProfileOptions(
|
||||||
|
string? launchProfile,
|
||||||
|
MeetingAssistantOptions defaultOptions,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles)
|
||||||
|
{
|
||||||
|
if (launchProfiles is not null)
|
||||||
|
{
|
||||||
|
return launchProfiles.GetRequiredProfile(launchProfile).Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(launchProfile) ||
|
||||||
|
launchProfile.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return defaultOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Launch profile '{launchProfile}' was requested, but no launch profile provider is registered.");
|
||||||
|
}
|
||||||
|
|
||||||
app.MapPost("/diagnostics/speaker-identities/merge", async (
|
app.MapPost("/diagnostics/speaker-identities/merge", async (
|
||||||
SpeakerIdentityMergeDiagnosticRequest request,
|
SpeakerIdentityMergeDiagnosticRequest request,
|
||||||
ISpeakerIdentityMergeService mergeService,
|
ISpeakerIdentityMergeService mergeService,
|
||||||
IOptions<MeetingAssistantOptions> options,
|
IOptions<MeetingAssistantOptions> options,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await MergeSpeakerIdentitiesAsync(null, request, mergeService, options.Value, null, cancellationToken));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/diagnostics/speaker-identities/merge", async (
|
||||||
|
string launchProfile,
|
||||||
|
SpeakerIdentityMergeDiagnosticRequest request,
|
||||||
|
ISpeakerIdentityMergeService mergeService,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await MergeSpeakerIdentitiesAsync(launchProfile, request, mergeService, options.Value, launchProfiles, cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> MergeSpeakerIdentitiesAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
SpeakerIdentityMergeDiagnosticRequest request,
|
||||||
|
ISpeakerIdentityMergeService mergeService,
|
||||||
|
MeetingAssistantOptions defaultOptions,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||||
var recentAge = request.RecentDays is > 0
|
var recentAge = request.RecentDays is > 0
|
||||||
? TimeSpan.FromDays(request.RecentDays.Value)
|
? TimeSpan.FromDays(request.RecentDays.Value)
|
||||||
: options.Value.SpeakerIdentification.MergeRecentIdentityAge;
|
: profileOptions.SpeakerIdentification.MergeRecentIdentityAge;
|
||||||
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
|
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
|
||||||
});
|
}
|
||||||
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||||
Results.Ok(await coordinator.ToggleAsync(cancellationToken)));
|
Results.Ok(await coordinator.ToggleAsync(cancellationToken)));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/recording/toggle", async (
|
||||||
|
string launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Results.Ok(await coordinator.ToggleAsync(launchProfile, cancellationToken)));
|
||||||
app.MapPost("/recording/start", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
app.MapPost("/recording/start", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||||
Results.Ok(await coordinator.StartAsync(cancellationToken)));
|
Results.Ok(await coordinator.StartAsync(cancellationToken)));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/recording/start", async (
|
||||||
|
string launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Results.Ok(await coordinator.StartAsync(launchProfile, cancellationToken)));
|
||||||
app.MapPost("/recording/stop", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
app.MapPost("/recording/stop", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||||
Results.Ok(await coordinator.StopAsync(cancellationToken)));
|
Results.Ok(await coordinator.StopAsync(cancellationToken)));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/recording/stop", async (
|
||||||
|
string launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Results.Ok(await coordinator.StopAsync(cancellationToken)));
|
||||||
app.MapPost("/meetings/current/summary/run", async (
|
app.MapPost("/meetings/current/summary/run", async (
|
||||||
MeetingRecordingCoordinator coordinator,
|
MeetingRecordingCoordinator coordinator,
|
||||||
IMeetingSummaryPipeline summaryPipeline,
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await RunCurrentSummaryAsync(
|
||||||
|
null,
|
||||||
|
coordinator,
|
||||||
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
|
null,
|
||||||
|
cancellationToken));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/meetings/current/summary/run", async (
|
||||||
|
string launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator,
|
||||||
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await RunCurrentSummaryAsync(
|
||||||
|
launchProfile,
|
||||||
|
coordinator,
|
||||||
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
|
launchProfiles,
|
||||||
|
cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> RunCurrentSummaryAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
MeetingRecordingCoordinator coordinator,
|
||||||
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
MeetingAssistantOptions defaultOptions,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (coordinator.CurrentArtifacts is null)
|
if (coordinator.CurrentArtifacts is null)
|
||||||
{
|
{
|
||||||
return Results.Conflict(new { error = "No meeting session has been started yet." });
|
return Results.Conflict(new { error = "No meeting session has been started yet." });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(await summaryPipeline.RunAsync(coordinator.CurrentArtifacts, cancellationToken));
|
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||||
});
|
return Results.Ok(await summaryPipeline.RunAsync(coordinator.CurrentArtifacts, profileOptions, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
app.MapPost("/meetings/summary/retry", async (
|
app.MapPost("/meetings/summary/retry", async (
|
||||||
SummaryRetryRequest request,
|
SummaryRetryRequest request,
|
||||||
IMeetingSummaryArtifactResolver artifactResolver,
|
IMeetingSummaryArtifactResolver artifactResolver,
|
||||||
IMeetingSummaryPipeline summaryPipeline,
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
{
|
await RetrySummaryAsync(
|
||||||
if (string.IsNullOrWhiteSpace(request.SummaryPath))
|
null,
|
||||||
{
|
request.SummaryPath,
|
||||||
return Results.BadRequest(new { error = "A summary path is required." });
|
artifactResolver,
|
||||||
}
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(request.SummaryPath, cancellationToken);
|
null,
|
||||||
if (artifacts is null)
|
cancellationToken));
|
||||||
{
|
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||||
return Results.NotFound(new { error = $"No meeting note links to summary '{request.SummaryPath}'." });
|
string launchProfile,
|
||||||
}
|
SummaryRetryRequest request,
|
||||||
|
IMeetingSummaryArtifactResolver artifactResolver,
|
||||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, cancellationToken));
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
});
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await RetrySummaryAsync(
|
||||||
|
launchProfile,
|
||||||
|
request.SummaryPath,
|
||||||
|
artifactResolver,
|
||||||
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
|
launchProfiles,
|
||||||
|
cancellationToken));
|
||||||
app.MapGet("/meetings/summary/retry", async (
|
app.MapGet("/meetings/summary/retry", async (
|
||||||
string summaryPath,
|
string summaryPath,
|
||||||
IMeetingSummaryArtifactResolver artifactResolver,
|
IMeetingSummaryArtifactResolver artifactResolver,
|
||||||
IMeetingSummaryPipeline summaryPipeline,
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await RetrySummaryAsync(
|
||||||
|
null,
|
||||||
|
summaryPath,
|
||||||
|
artifactResolver,
|
||||||
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
|
null,
|
||||||
|
cancellationToken));
|
||||||
|
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||||
|
string launchProfile,
|
||||||
|
string summaryPath,
|
||||||
|
IMeetingSummaryArtifactResolver artifactResolver,
|
||||||
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider launchProfiles,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await RetrySummaryAsync(
|
||||||
|
launchProfile,
|
||||||
|
summaryPath,
|
||||||
|
artifactResolver,
|
||||||
|
summaryPipeline,
|
||||||
|
options.Value,
|
||||||
|
launchProfiles,
|
||||||
|
cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> RetrySummaryAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
string? summaryPath,
|
||||||
|
IMeetingSummaryArtifactResolver artifactResolver,
|
||||||
|
IMeetingSummaryPipeline summaryPipeline,
|
||||||
|
MeetingAssistantOptions defaultOptions,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(summaryPath))
|
if (string.IsNullOrWhiteSpace(summaryPath))
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { error = "A summary path is required." });
|
return Results.BadRequest(new { error = "A summary path is required." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, cancellationToken);
|
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||||
|
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken);
|
||||||
if (artifacts is null)
|
if (artifacts is null)
|
||||||
{
|
{
|
||||||
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
|
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, cancellationToken));
|
return Results.Ok(await summaryPipeline.RunAsync(artifacts, profileOptions, cancellationToken));
|
||||||
});
|
}
|
||||||
|
|
||||||
app.MapPost("/asr/transcribe-file", async (
|
app.MapPost("/asr/transcribe-file", async (
|
||||||
AsrDiagnosticRequest request,
|
AsrDiagnosticRequest request,
|
||||||
AsrDiagnosticService diagnostics,
|
AsrDiagnosticService diagnostics,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await TranscribeFileAsync(null, request, diagnostics, cancellationToken));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/asr/transcribe-file", async (
|
||||||
|
string launchProfile,
|
||||||
|
AsrDiagnosticRequest request,
|
||||||
|
AsrDiagnosticService diagnostics,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await TranscribeFileAsync(launchProfile, request, diagnostics, cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> TranscribeFileAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
AsrDiagnosticRequest request,
|
||||||
|
AsrDiagnosticService diagnostics,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Path))
|
if (string.IsNullOrWhiteSpace(request.Path))
|
||||||
{
|
{
|
||||||
@@ -182,7 +371,7 @@ app.MapPost("/asr/transcribe-file", async (
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, cancellationToken));
|
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, launchProfile, cancellationToken));
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException exception)
|
catch (FileNotFoundException exception)
|
||||||
{
|
{
|
||||||
@@ -199,11 +388,24 @@ app.MapPost("/asr/transcribe-file", async (
|
|||||||
statusCode: StatusCodes.Status502BadGateway,
|
statusCode: StatusCodes.Status502BadGateway,
|
||||||
title: "ASR backend failed while processing the WAV file.");
|
title: "ASR backend failed while processing the WAV file.");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
app.MapPost("/asr/diarize-file", async (
|
app.MapPost("/asr/diarize-file", async (
|
||||||
AsrDiagnosticRequest request,
|
AsrDiagnosticRequest request,
|
||||||
AsrDiagnosticService diagnostics,
|
AsrDiagnosticService diagnostics,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
|
await DiarizeFileAsync(null, request, diagnostics, cancellationToken));
|
||||||
|
app.MapPost("/profiles/{launchProfile}/asr/diarize-file", async (
|
||||||
|
string launchProfile,
|
||||||
|
AsrDiagnosticRequest request,
|
||||||
|
AsrDiagnosticService diagnostics,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await DiarizeFileAsync(launchProfile, request, diagnostics, cancellationToken));
|
||||||
|
|
||||||
|
static async Task<IResult> DiarizeFileAsync(
|
||||||
|
string? launchProfile,
|
||||||
|
AsrDiagnosticRequest request,
|
||||||
|
AsrDiagnosticService diagnostics,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Path))
|
if (string.IsNullOrWhiteSpace(request.Path))
|
||||||
{
|
{
|
||||||
@@ -221,6 +423,7 @@ app.MapPost("/asr/diarize-file", async (
|
|||||||
return Results.Ok(await diagnostics.DiarizeWavAsync(
|
return Results.Ok(await diagnostics.DiarizeWavAsync(
|
||||||
fullPath,
|
fullPath,
|
||||||
new SpeechRecognitionPipelineOptions(request.NumSpeakers),
|
new SpeechRecognitionPipelineOptions(request.NumSpeakers),
|
||||||
|
launchProfile,
|
||||||
cancellationToken));
|
cancellationToken));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -230,7 +433,7 @@ app.MapPost("/asr/diarize-file", async (
|
|||||||
statusCode: StatusCodes.Status502BadGateway,
|
statusCode: StatusCodes.Status502BadGateway,
|
||||||
title: "ASR diarization backend failed while processing the WAV file.");
|
title: "ASR diarization backend failed while processing the WAV file.");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ public interface IRecordedAudioStore
|
|||||||
{
|
{
|
||||||
Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken);
|
Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IRecordedAudioSink> CreateSessionAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateSessionAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken);
|
Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ public interface ITranscriptStore
|
|||||||
{
|
{
|
||||||
Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken);
|
Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<TranscriptSession> CreateSessionAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return CreateSessionAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
|
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task ReplaceAsync(
|
Task ReplaceAsync(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Speakers;
|
using MeetingAssistant.Speakers;
|
||||||
using MeetingAssistant.Summary;
|
using MeetingAssistant.Summary;
|
||||||
@@ -20,7 +21,9 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
private readonly IRecordedAudioStore recordedAudioStore;
|
private readonly IRecordedAudioStore recordedAudioStore;
|
||||||
private readonly IMeetingSummaryPipeline summaryPipeline;
|
private readonly IMeetingSummaryPipeline summaryPipeline;
|
||||||
private readonly ISpeakerIdentificationService? speakerIdentificationService;
|
private readonly ISpeakerIdentificationService? speakerIdentificationService;
|
||||||
|
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
|
||||||
private readonly IDictationWordStore dictationWordStore;
|
private readonly IDictationWordStore dictationWordStore;
|
||||||
|
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly MeetingAssistantOptions options;
|
||||||
private readonly ILogger<MeetingRecordingCoordinator> logger;
|
private readonly ILogger<MeetingRecordingCoordinator> logger;
|
||||||
private readonly SemaphoreSlim gate = new(1, 1);
|
private readonly SemaphoreSlim gate = new(1, 1);
|
||||||
@@ -42,7 +45,9 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
ILogger<MeetingRecordingCoordinator> logger,
|
ILogger<MeetingRecordingCoordinator> logger,
|
||||||
IMeetingMetadataProvider? meetingMetadataProvider = null,
|
IMeetingMetadataProvider? meetingMetadataProvider = null,
|
||||||
ISpeakerIdentificationService? speakerIdentificationService = null,
|
ISpeakerIdentificationService? speakerIdentificationService = null,
|
||||||
IDictationWordStore? dictationWordStore = null)
|
IDictationWordStore? dictationWordStore = null,
|
||||||
|
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles = null)
|
||||||
{
|
{
|
||||||
this.audioSource = audioSource;
|
this.audioSource = audioSource;
|
||||||
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
|
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
|
||||||
@@ -55,6 +60,8 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
this.summaryPipeline = summaryPipeline;
|
this.summaryPipeline = summaryPipeline;
|
||||||
this.dictationWordStore = dictationWordStore ?? new EmptyDictationWordStore();
|
this.dictationWordStore = dictationWordStore ?? new EmptyDictationWordStore();
|
||||||
this.speakerIdentificationService = speakerIdentificationService;
|
this.speakerIdentificationService = speakerIdentificationService;
|
||||||
|
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
|
||||||
|
this.launchProfiles = launchProfiles;
|
||||||
this.options = options.Value;
|
this.options = options.Value;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
@@ -71,13 +78,27 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
||||||
|
|
||||||
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ToggleAsync(null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordingStatus> ToggleAsync(
|
||||||
|
string? launchProfileName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return IsRecording
|
return IsRecording
|
||||||
? await StopAsync(cancellationToken)
|
? await StopAsync(cancellationToken)
|
||||||
: await StartAsync(cancellationToken);
|
: await StartAsync(launchProfileName, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await StartAsync(null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RecordingStatus> StartAsync(
|
||||||
|
string? launchProfileName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await gate.WaitAsync(cancellationToken);
|
await gate.WaitAsync(cancellationToken);
|
||||||
try
|
try
|
||||||
@@ -87,11 +108,12 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return CurrentStatus;
|
return CurrentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSession = await transcriptStore.CreateSessionAsync(cancellationToken);
|
var runOptions = ResolveOptions(launchProfileName);
|
||||||
var recordedAudio = await recordedAudioStore.CreateSessionAsync(cancellationToken);
|
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||||
|
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||||
var startedAt = DateTimeOffset.Now;
|
var startedAt = DateTimeOffset.Now;
|
||||||
var assistantContextPath = GetAssistantContextPath(startedAt);
|
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
|
||||||
var summaryPath = GetSummaryPath(startedAt);
|
var summaryPath = GetSummaryPath(startedAt, runOptions);
|
||||||
currentMeetingNote = await meetingNoteStore.SaveAsync(
|
currentMeetingNote = await meetingNoteStore.SaveAsync(
|
||||||
MeetingNoteTemplate.Create(
|
MeetingNoteTemplate.Create(
|
||||||
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
|
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
|
||||||
@@ -100,16 +122,14 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
transcriptPath: currentSession.TranscriptPath,
|
transcriptPath: currentSession.TranscriptPath,
|
||||||
assistantContextPath: assistantContextPath,
|
assistantContextPath: assistantContextPath,
|
||||||
summaryPath: summaryPath),
|
summaryPath: summaryPath),
|
||||||
|
runOptions,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
currentArtifacts = new MeetingSessionArtifacts(
|
currentArtifacts = new MeetingSessionArtifacts(
|
||||||
currentMeetingNote.Path,
|
currentMeetingNote.Path,
|
||||||
currentSession.TranscriptPath,
|
currentSession.TranscriptPath,
|
||||||
assistantContextPath,
|
assistantContextPath,
|
||||||
summaryPath);
|
summaryPath);
|
||||||
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, "", null, cancellationToken);
|
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
|
||||||
_ = Task.Run(
|
|
||||||
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, currentArtifacts, currentMeetingNote.Path),
|
|
||||||
CancellationToken.None);
|
|
||||||
await transcriptStore.UpdateMetadataAsync(
|
await transcriptStore.UpdateMetadataAsync(
|
||||||
currentSession,
|
currentSession,
|
||||||
currentArtifacts,
|
currentArtifacts,
|
||||||
@@ -118,18 +138,28 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
await OpenMeetingNoteAsync(currentMeetingNote.Path, cancellationToken);
|
await OpenMeetingNoteAsync(currentMeetingNote.Path, cancellationToken);
|
||||||
var captureCancellation = new CancellationTokenSource();
|
var captureCancellation = new CancellationTokenSource();
|
||||||
var transcriptionCancellation = new CancellationTokenSource();
|
var transcriptionCancellation = new CancellationTokenSource();
|
||||||
var pipeline = speechRecognitionPipelineFactory.Create();
|
var pipeline = speechRecognitionPipelineFactory.Create(launchProfileName);
|
||||||
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken);
|
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(
|
||||||
|
runOptions,
|
||||||
|
currentMeetingNote.Path,
|
||||||
|
cancellationToken);
|
||||||
var run = new RecordingRun(
|
var run = new RecordingRun(
|
||||||
captureCancellation,
|
captureCancellation,
|
||||||
transcriptionCancellation,
|
transcriptionCancellation,
|
||||||
|
currentSession,
|
||||||
|
currentMeetingNote.Path,
|
||||||
|
currentArtifacts,
|
||||||
recordedAudio,
|
recordedAudio,
|
||||||
pipeline,
|
pipeline,
|
||||||
pipelineOptions,
|
pipelineOptions,
|
||||||
options.SpeakerIdentification.LiveSampleBufferDuration,
|
runOptions,
|
||||||
options.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
|
||||||
run.Task = Task.Run(() => RecordAsync(currentSession, run), CancellationToken.None);
|
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
||||||
|
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
||||||
currentRun = run;
|
currentRun = run;
|
||||||
|
_ = Task.Run(
|
||||||
|
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
|
||||||
|
CancellationToken.None);
|
||||||
logger.LogInformation("Meeting recording started");
|
logger.LogInformation("Meeting recording started");
|
||||||
|
|
||||||
return CurrentStatus;
|
return CurrentStatus;
|
||||||
@@ -170,7 +200,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await run.Task.WaitAsync(options.Recording.StopProcessingTimeout, cancellationToken);
|
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -187,19 +217,17 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return CurrentStatus;
|
return CurrentStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RecordAsync(
|
private async Task RecordAsync(RecordingRun run)
|
||||||
TranscriptSession session,
|
|
||||||
RecordingRun run)
|
|
||||||
{
|
{
|
||||||
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
||||||
var liveTranscriptTask = Task.Run(
|
var liveTranscriptTask = Task.Run(
|
||||||
() => StoreLiveTranscriptAsync(session, run, run.TranscriptionCancellation),
|
() => StoreLiveTranscriptAsync(run, run.TranscriptionCancellation),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var captureTask = Task.Run(
|
var captureTask = Task.Run(
|
||||||
() => CaptureAudioAsync(run),
|
() => CaptureAudioAsync(run),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
var liveIdentificationTask = Task.Run(
|
var liveIdentificationTask = Task.Run(
|
||||||
() => IdentifyLiveSpeakersAsync(session, run, run.TranscriptionCancellation),
|
() => IdentifyLiveSpeakersAsync(run, run.TranscriptionCancellation),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -210,30 +238,34 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
run.CancelLiveIdentification();
|
run.CancelLiveIdentification();
|
||||||
await liveIdentificationTask;
|
await liveIdentificationTask;
|
||||||
await run.RecordedAudio.DisposeAsync();
|
await run.RecordedAudio.DisposeAsync();
|
||||||
var artifacts = currentArtifacts;
|
var artifacts = run.Artifacts;
|
||||||
if (artifacts is not null && HasConfiguredFinalizer())
|
if (HasConfiguredFinalizer(run.Options))
|
||||||
{
|
{
|
||||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
await TransitionMeetingAsync(
|
||||||
artifacts,
|
run,
|
||||||
AssistantContextState.SpeakerRecognition,
|
AssistantContextState.SpeakerRecognition,
|
||||||
run.TranscriptionCancellation);
|
run.TranscriptionCancellation);
|
||||||
}
|
}
|
||||||
|
|
||||||
await RewriteTranscriptWithFinishedSegmentsAsync(session, run.Pipeline, run, run.TranscriptionCancellation);
|
await RewriteTranscriptWithFinishedSegmentsAsync(run, run.TranscriptionCancellation);
|
||||||
var completedMeetingNote = await CompleteMeetingNoteAsync(run.TranscriptionCancellation);
|
var completedMeetingNote = await CompleteMeetingNoteAsync(
|
||||||
if (artifacts is not null && completedMeetingNote is not null)
|
run.MeetingNotePath,
|
||||||
|
run.Options,
|
||||||
|
run.TranscriptionCancellation);
|
||||||
|
if (completedMeetingNote is not null)
|
||||||
{
|
{
|
||||||
await transcriptStore.UpdateMetadataAsync(
|
await transcriptStore.UpdateMetadataAsync(
|
||||||
session,
|
run.Session,
|
||||||
|
artifacts,
|
||||||
|
completedMeetingNote,
|
||||||
|
run.TranscriptionCancellation);
|
||||||
|
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
||||||
artifacts,
|
artifacts,
|
||||||
completedMeetingNote,
|
completedMeetingNote,
|
||||||
run.TranscriptionCancellation);
|
run.TranscriptionCancellation);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artifacts is not null)
|
await RunSummaryAsync(run, run.TranscriptionCancellation);
|
||||||
{
|
|
||||||
await RunSummaryAsync(artifacts, run.TranscriptionCancellation);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
|
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -298,7 +330,6 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task StoreLiveTranscriptAsync(
|
private async Task StoreLiveTranscriptAsync(
|
||||||
TranscriptSession session,
|
|
||||||
RecordingRun run,
|
RecordingRun run,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -307,22 +338,21 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
run.TryAddSpeakerSample(segment);
|
run.TryAddSpeakerSample(segment);
|
||||||
var relabeledSegment = run.Relabel(segment);
|
var relabeledSegment = run.Relabel(segment);
|
||||||
run.AddLiveSegment(relabeledSegment);
|
run.AddLiveSegment(relabeledSegment);
|
||||||
await transcriptStore.AppendAsync(session, relabeledSegment, cancellationToken);
|
await transcriptStore.AppendAsync(run.Session, relabeledSegment, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task IdentifyLiveSpeakersAsync(
|
private async Task IdentifyLiveSpeakersAsync(
|
||||||
TranscriptSession session,
|
|
||||||
RecordingRun run,
|
RecordingRun run,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (speakerIdentificationService is null || !options.SpeakerIdentification.Enabled)
|
if (speakerIdentificationService is null || !run.Options.SpeakerIdentification.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var initialDelay = options.SpeakerIdentification.InitialDelay;
|
var initialDelay = run.Options.SpeakerIdentification.InitialDelay;
|
||||||
var interval = options.SpeakerIdentification.Interval;
|
var interval = run.Options.SpeakerIdentification.Interval;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (initialDelay > TimeSpan.Zero)
|
if (initialDelay > TimeSpan.Zero)
|
||||||
@@ -332,7 +362,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
while (!run.LiveIdentificationCancellation.IsCancellationRequested)
|
while (!run.LiveIdentificationCancellation.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await IdentifyLiveSpeakersOnceAsync(session, run, run.LiveIdentificationCancellation);
|
await IdentifyLiveSpeakersOnceAsync(run, run.LiveIdentificationCancellation);
|
||||||
await Task.Delay(interval > TimeSpan.Zero ? interval : TimeSpan.FromMinutes(1), run.LiveIdentificationCancellation);
|
await Task.Delay(interval > TimeSpan.Zero ? interval : TimeSpan.FromMinutes(1), run.LiveIdentificationCancellation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,11 +372,10 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task IdentifyLiveSpeakersOnceAsync(
|
private async Task IdentifyLiveSpeakersOnceAsync(
|
||||||
TranscriptSession session,
|
|
||||||
RecordingRun run,
|
RecordingRun run,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
if (string.IsNullOrWhiteSpace(run.MeetingNotePath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -360,7 +389,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken);
|
||||||
var checkpoint = run.CreateLiveIdentificationCheckpoint(meetingNote);
|
var checkpoint = run.CreateLiveIdentificationCheckpoint(meetingNote);
|
||||||
if (checkpoint is null)
|
if (checkpoint is null)
|
||||||
{
|
{
|
||||||
@@ -368,14 +397,23 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result = await speakerIdentificationService!.IdentifyKnownSpeakersAsync(
|
var result = await speakerIdentificationService!.IdentifyKnownSpeakersAsync(
|
||||||
new SpeakerIdentificationRequest("", meetingNote, segments, samples),
|
new SpeakerIdentificationRequest(
|
||||||
|
"",
|
||||||
|
meetingNote,
|
||||||
|
segments,
|
||||||
|
samples,
|
||||||
|
run.GetSpeakerMappingsSnapshot()),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
run.MarkLiveIdentificationAttempted(checkpoint);
|
run.MarkLiveIdentificationAttempted(checkpoint);
|
||||||
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
||||||
|
result.AttendeeMatches,
|
||||||
|
run.MeetingNotePath,
|
||||||
|
run.Options,
|
||||||
|
cancellationToken);
|
||||||
if (run.AddSpeakerMappings(result.SpeakerMappings))
|
if (run.AddSpeakerMappings(result.SpeakerMappings))
|
||||||
{
|
{
|
||||||
await transcriptStore.ReplaceAsync(
|
await transcriptStore.ReplaceAsync(
|
||||||
session,
|
run.Session,
|
||||||
run.GetRelabeledLiveSegmentsSnapshot(),
|
run.GetRelabeledLiveSegmentsSnapshot(),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -392,52 +430,75 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
private async Task ApplyMeetingMetadataWhenAvailableAsync(
|
private async Task ApplyMeetingMetadataWhenAvailableAsync(
|
||||||
DateTimeOffset startedAt,
|
DateTimeOffset startedAt,
|
||||||
MeetingSessionArtifacts artifacts,
|
RecordingRun run)
|
||||||
string meetingNotePath)
|
|
||||||
{
|
{
|
||||||
var metadata = await GetMeetingMetadataAsync(
|
|
||||||
startedAt,
|
|
||||||
options.Recording.BackgroundMetadataLookupTimeout,
|
|
||||||
CancellationToken.None);
|
|
||||||
if (metadata is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await gate.WaitAsync(CancellationToken.None);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, CancellationToken.None);
|
var metadata = await GetMeetingMetadataAsync(
|
||||||
ApplyMeetingMetadata(meetingNote, metadata);
|
startedAt,
|
||||||
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, CancellationToken.None);
|
run.Options.Recording.BackgroundMetadataLookupTimeout,
|
||||||
if (currentMeetingNote?.Path == meetingNote.Path)
|
CancellationToken.None);
|
||||||
|
if (metadata is null)
|
||||||
{
|
{
|
||||||
currentMeetingNote = meetingNote;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
await gate.WaitAsync(CancellationToken.None);
|
||||||
artifacts,
|
try
|
||||||
metadata.Agenda,
|
{
|
||||||
metadata.ScheduledEnd,
|
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
|
||||||
CancellationToken.None);
|
await ApplyMeetingMetadataAsync(meetingNote, metadata, CancellationToken.None);
|
||||||
logger.LogInformation(
|
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
|
||||||
"Applied Outlook meeting metadata to {MeetingNotePath}",
|
if (currentMeetingNote?.Path == meetingNote.Path)
|
||||||
meetingNotePath);
|
{
|
||||||
|
currentMeetingNote = meetingNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
||||||
|
run.Artifacts,
|
||||||
|
meetingNote,
|
||||||
|
metadata.Agenda,
|
||||||
|
metadata.ScheduledEnd,
|
||||||
|
CancellationToken.None);
|
||||||
|
logger.LogInformation(
|
||||||
|
"Applied Outlook meeting metadata to {MeetingNotePath}",
|
||||||
|
run.MeetingNotePath);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
exception,
|
exception,
|
||||||
"Could not apply Outlook meeting metadata to {MeetingNotePath}",
|
"Could not apply Outlook meeting metadata to {MeetingNotePath}",
|
||||||
meetingNotePath);
|
run.MeetingNotePath);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
gate.Release();
|
try
|
||||||
|
{
|
||||||
|
await TransitionMeetingAsync(
|
||||||
|
run,
|
||||||
|
AssistantContextState.Transcribing,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Could not update assistant context state to transcribing for {MeetingNotePath}",
|
||||||
|
run.MeetingNotePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyMeetingMetadata(MeetingNote meetingNote, MeetingMetadata metadata)
|
private async Task ApplyMeetingMetadataAsync(
|
||||||
|
MeetingNote meetingNote,
|
||||||
|
MeetingMetadata metadata,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||||
{
|
{
|
||||||
@@ -446,7 +507,30 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
if (metadata.Attendees.Count > 0)
|
if (metadata.Attendees.Count > 0)
|
||||||
{
|
{
|
||||||
meetingNote.Frontmatter.Attendees = metadata.Attendees.ToList();
|
meetingNote.Frontmatter.Attendees = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>> CanonicalizeAttendeesAsync(
|
||||||
|
IReadOnlyList<string> attendees,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (await attendeeCanonicalizer.CanonicalizeAsync(attendees, cancellationToken)).ToList();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Could not canonicalize meeting attendees; writing raw attendee names");
|
||||||
|
return attendees
|
||||||
|
.Select(NormalizeAttendeeName)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,14 +587,12 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task RewriteTranscriptWithFinishedSegmentsAsync(
|
private async Task RewriteTranscriptWithFinishedSegmentsAsync(
|
||||||
TranscriptSession session,
|
|
||||||
ISpeechRecognitionPipeline pipeline,
|
|
||||||
RecordingRun run,
|
RecordingRun run,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
|
var finishedSegments = await run.Pipeline.ReadFinishedTranscriptAsync(
|
||||||
run.RecordedAudio.AudioPath,
|
run.RecordedAudio.AudioPath,
|
||||||
await BuildSpeechRecognitionPipelineOptionsAsync(cancellationToken),
|
await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (finishedSegments.Count == 0)
|
if (finishedSegments.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -524,28 +606,43 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
run.RecordedAudio.AudioPath,
|
run.RecordedAudio.AudioPath,
|
||||||
finishedSegments,
|
finishedSegments,
|
||||||
run.GetSpeakerSamplesSnapshot(),
|
run.GetSpeakerSamplesSnapshot(),
|
||||||
|
run.GetSpeakerMappingsSnapshot(),
|
||||||
|
run.MeetingNotePath,
|
||||||
|
run.Options,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
await transcriptStore.ReplaceAsync(session, finishedSegments, cancellationToken);
|
await transcriptStore.ReplaceAsync(run.Session, finishedSegments, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync(
|
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync(
|
||||||
string audioPath,
|
string audioPath,
|
||||||
IReadOnlyList<TranscriptionSegment> finishedSegments,
|
IReadOnlyList<TranscriptionSegment> finishedSegments,
|
||||||
IReadOnlyList<SpeakerAudioSample> samples,
|
IReadOnlyList<SpeakerAudioSample> samples,
|
||||||
|
IReadOnlyDictionary<string, string> knownSpeakerMappings,
|
||||||
|
string meetingNotePath,
|
||||||
|
MeetingAssistantOptions runOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath))
|
||||||
{
|
{
|
||||||
return finishedSegments;
|
return finishedSegments;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||||
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
||||||
new SpeakerIdentificationRequest(audioPath, meetingNote, finishedSegments, samples),
|
new SpeakerIdentificationRequest(
|
||||||
|
audioPath,
|
||||||
|
meetingNote,
|
||||||
|
finishedSegments,
|
||||||
|
samples,
|
||||||
|
knownSpeakerMappings),
|
||||||
|
cancellationToken);
|
||||||
|
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
||||||
|
result.AttendeeMatches,
|
||||||
|
meetingNotePath,
|
||||||
|
runOptions,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
await AddIdentifiedSpeakersToMeetingAttendeesAsync(result.AttendeeMatches, cancellationToken);
|
|
||||||
return result.Segments;
|
return result.Segments;
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
@@ -561,14 +658,16 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
||||||
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
|
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
|
||||||
|
string meetingNotePath,
|
||||||
|
MeetingAssistantOptions runOptions,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (matches is null || matches.Count == 0 || string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
if (matches is null || matches.Count == 0 || string.IsNullOrWhiteSpace(meetingNotePath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||||
var existingNames = meetingNote.Frontmatter.Attendees
|
var existingNames = meetingNote.Frontmatter.Attendees
|
||||||
.Select(NormalizeAttendeeName)
|
.Select(NormalizeAttendeeName)
|
||||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
@@ -603,45 +702,56 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
||||||
|
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
currentMeetingNote = savedMeetingNote;
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
|
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
|
||||||
currentMeetingNote.Path);
|
savedMeetingNote.Path);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MeetingNote?> CompleteMeetingNoteAsync(CancellationToken cancellationToken)
|
private async Task<MeetingNote?> CompleteMeetingNoteAsync(
|
||||||
|
string meetingNotePath,
|
||||||
|
MeetingAssistantOptions runOptions,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
if (string.IsNullOrWhiteSpace(meetingNotePath))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||||
meetingNote.Frontmatter.EndTime = DateTimeOffset.Now;
|
meetingNote.Frontmatter.EndTime = DateTimeOffset.Now;
|
||||||
currentMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
||||||
return currentMeetingNote;
|
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
currentMeetingNote = savedMeetingNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedMeetingNote;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeAttendeeName(string attendee)
|
private static string NormalizeAttendeeName(string attendee)
|
||||||
{
|
{
|
||||||
var trimmed = attendee.Trim();
|
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
||||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
|
||||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunSummaryAsync(
|
private async Task RunSummaryAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
RecordingRun run,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
await TransitionMeetingAsync(
|
||||||
artifacts,
|
run,
|
||||||
AssistantContextState.Summarizing,
|
AssistantContextState.Summarizing,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
var result = await summaryPipeline.RunAsync(artifacts, cancellationToken);
|
var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken);
|
||||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
await TransitionMeetingAsync(
|
||||||
artifacts,
|
run,
|
||||||
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
@@ -652,47 +762,82 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
||||||
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
await TransitionMeetingAsync(
|
||||||
artifacts,
|
run,
|
||||||
AssistantContextState.Error,
|
AssistantContextState.Error,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasConfiguredFinalizer()
|
private async Task TransitionMeetingAsync(
|
||||||
|
RecordingRun run,
|
||||||
|
AssistantContextState state,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return options.Recording.TranscriptionProvider switch
|
if (!run.TryTransitionTo(state))
|
||||||
{
|
{
|
||||||
"funasr" => options.FunAsr.Diarization.Enabled,
|
return;
|
||||||
"whisper-local" => options.WhisperLocal.Diarization.Enabled,
|
}
|
||||||
|
|
||||||
|
await meetingArtifactStore.UpdateAssistantContextStateAsync(
|
||||||
|
run.Artifacts,
|
||||||
|
state,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasConfiguredFinalizer(MeetingAssistantOptions runOptions)
|
||||||
|
{
|
||||||
|
return runOptions.Recording.TranscriptionProvider switch
|
||||||
|
{
|
||||||
|
"funasr" => runOptions.FunAsr.Diarization.Enabled,
|
||||||
|
"whisper-local" => runOptions.WhisperLocal.Diarization.Enabled,
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SpeechRecognitionPipelineOptions> BuildSpeechRecognitionPipelineOptionsAsync(
|
private async Task<SpeechRecognitionPipelineOptions> BuildSpeechRecognitionPipelineOptionsAsync(
|
||||||
|
MeetingAssistantOptions runOptions,
|
||||||
|
string? meetingNotePath,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var dictationWords = await dictationWordStore.ReadWordsAsync(cancellationToken);
|
var dictationWords = await dictationWordStore.ReadWordsAsync(runOptions, cancellationToken);
|
||||||
if (string.IsNullOrWhiteSpace(currentMeetingNote?.Path))
|
if (string.IsNullOrWhiteSpace(meetingNotePath))
|
||||||
{
|
{
|
||||||
return new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
return new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
var meetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||||
var attendeeCount = meetingNote.Frontmatter.Attendees.Count(attendee => !string.IsNullOrWhiteSpace(attendee));
|
var attendeeCount = meetingNote.Frontmatter.Attendees.Count(attendee => !string.IsNullOrWhiteSpace(attendee));
|
||||||
return attendeeCount > 1
|
return attendeeCount > 1
|
||||||
? new SpeechRecognitionPipelineOptions(attendeeCount, dictationWords)
|
? new SpeechRecognitionPipelineOptions(attendeeCount, dictationWords)
|
||||||
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetAssistantContextPath(DateTimeOffset startedAt)
|
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
||||||
{
|
{
|
||||||
return GetMeetingArtifactPath(options.Vault, options.Vault.AssistantContextFolder, startedAt, "assistant-context");
|
if (launchProfiles is not null)
|
||||||
|
{
|
||||||
|
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
||||||
|
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Launch profile '{launchProfileName}' was requested, but no launch profile provider is registered.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetSummaryPath(DateTimeOffset startedAt)
|
private string GetAssistantContextPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
|
||||||
{
|
{
|
||||||
return GetMeetingArtifactPath(options.Vault, options.Vault.SummariesFolder, startedAt, "summary");
|
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, "assistant-context");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSummaryPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
|
||||||
|
{
|
||||||
|
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, "summary");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetMeetingArtifactPath(
|
private static string GetMeetingArtifactPath(
|
||||||
@@ -702,7 +847,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
string artifactName)
|
string artifactName)
|
||||||
{
|
{
|
||||||
var folder = VaultPath.Resolve(vault, configuredFolder);
|
var folder = VaultPath.Resolve(vault, configuredFolder);
|
||||||
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss}-{artifactName}.md");
|
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmmss-fffffff}-{artifactName}.md");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CompleteRunAsync(RecordingRun run)
|
private async Task CompleteRunAsync(RecordingRun run)
|
||||||
@@ -718,6 +863,9 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
if (ReferenceEquals(currentRun, run))
|
if (ReferenceEquals(currentRun, run))
|
||||||
{
|
{
|
||||||
currentRun = null;
|
currentRun = null;
|
||||||
|
currentSession = null;
|
||||||
|
currentMeetingNote = null;
|
||||||
|
currentArtifacts = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -730,6 +878,7 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
private sealed class RecordingRun : IDisposable
|
private sealed class RecordingRun : IDisposable
|
||||||
{
|
{
|
||||||
private int completed;
|
private int completed;
|
||||||
|
private readonly object stateGate = new();
|
||||||
private readonly object liveSegmentsGate = new();
|
private readonly object liveSegmentsGate = new();
|
||||||
private readonly object liveIdentificationGate = new();
|
private readonly object liveIdentificationGate = new();
|
||||||
private readonly List<TranscriptionSegment> liveSegments = [];
|
private readonly List<TranscriptionSegment> liveSegments = [];
|
||||||
@@ -740,18 +889,26 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
public RecordingRun(
|
public RecordingRun(
|
||||||
CancellationTokenSource captureCancellation,
|
CancellationTokenSource captureCancellation,
|
||||||
CancellationTokenSource transcriptionCancellation,
|
CancellationTokenSource transcriptionCancellation,
|
||||||
|
TranscriptSession session,
|
||||||
|
string meetingNotePath,
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
IRecordedAudioSink recordedAudio,
|
IRecordedAudioSink recordedAudio,
|
||||||
ISpeechRecognitionPipeline pipeline,
|
ISpeechRecognitionPipeline pipeline,
|
||||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
TimeSpan liveSampleBufferDuration,
|
TimeSpan liveSampleBufferDuration,
|
||||||
int maxSpeakerSamples)
|
int maxSpeakerSamples)
|
||||||
{
|
{
|
||||||
CaptureCancellationSource = captureCancellation;
|
CaptureCancellationSource = captureCancellation;
|
||||||
TranscriptionCancellationSource = transcriptionCancellation;
|
TranscriptionCancellationSource = transcriptionCancellation;
|
||||||
LiveIdentificationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(transcriptionCancellation.Token);
|
LiveIdentificationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(transcriptionCancellation.Token);
|
||||||
|
Session = session;
|
||||||
|
MeetingNotePath = meetingNotePath;
|
||||||
|
Artifacts = artifacts;
|
||||||
RecordedAudio = recordedAudio;
|
RecordedAudio = recordedAudio;
|
||||||
Pipeline = pipeline;
|
Pipeline = pipeline;
|
||||||
PipelineOptions = pipelineOptions;
|
PipelineOptions = pipelineOptions;
|
||||||
|
Options = options;
|
||||||
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
|
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,10 +920,18 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
public IRecordedAudioSink RecordedAudio { get; }
|
public IRecordedAudioSink RecordedAudio { get; }
|
||||||
|
|
||||||
|
public TranscriptSession Session { get; }
|
||||||
|
|
||||||
|
public string MeetingNotePath { get; }
|
||||||
|
|
||||||
|
public MeetingSessionArtifacts Artifacts { get; }
|
||||||
|
|
||||||
public ISpeechRecognitionPipeline Pipeline { get; }
|
public ISpeechRecognitionPipeline Pipeline { get; }
|
||||||
|
|
||||||
public SpeechRecognitionPipelineOptions PipelineOptions { get; }
|
public SpeechRecognitionPipelineOptions PipelineOptions { get; }
|
||||||
|
|
||||||
|
public MeetingAssistantOptions Options { get; }
|
||||||
|
|
||||||
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
|
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
|
||||||
|
|
||||||
public CancellationToken TranscriptionCancellation => TranscriptionCancellationSource.Token;
|
public CancellationToken TranscriptionCancellation => TranscriptionCancellationSource.Token;
|
||||||
@@ -777,6 +942,8 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
|
|
||||||
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
|
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
|
||||||
|
|
||||||
|
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||||
|
|
||||||
public void StopCapture()
|
public void StopCapture()
|
||||||
{
|
{
|
||||||
CaptureCancellationSource.Cancel();
|
CaptureCancellationSource.Cancel();
|
||||||
@@ -792,6 +959,20 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
LiveIdentificationCancellationSource.Cancel();
|
LiveIdentificationCancellationSource.Cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool TryTransitionTo(AssistantContextState state)
|
||||||
|
{
|
||||||
|
lock (stateGate)
|
||||||
|
{
|
||||||
|
if (StateRank(state) <= StateRank(ContextState))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ContextState = state;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void AddLiveSegment(TranscriptionSegment segment)
|
public void AddLiveSegment(TranscriptionSegment segment)
|
||||||
{
|
{
|
||||||
lock (liveSegmentsGate)
|
lock (liveSegmentsGate)
|
||||||
@@ -883,6 +1064,14 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> GetSpeakerMappingsSnapshot()
|
||||||
|
{
|
||||||
|
return speakerMappings.ToDictionary(
|
||||||
|
pair => pair.Key,
|
||||||
|
pair => pair.Value,
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private IReadOnlyList<string> GetUnmappedSampleSpeakers()
|
private IReadOnlyList<string> GetUnmappedSampleSpeakers()
|
||||||
{
|
{
|
||||||
return GetSpeakerSamplesSnapshot()
|
return GetSpeakerSamplesSnapshot()
|
||||||
@@ -917,6 +1106,20 @@ public sealed class MeetingRecordingCoordinator
|
|||||||
LiveIdentificationCancellationSource.Dispose();
|
LiveIdentificationCancellationSource.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int StateRank(AssistantContextState state)
|
||||||
|
{
|
||||||
|
return state switch
|
||||||
|
{
|
||||||
|
AssistantContextState.CollectingMetadata => 0,
|
||||||
|
AssistantContextState.Transcribing => 1,
|
||||||
|
AssistantContextState.SpeakerRecognition => 2,
|
||||||
|
AssistantContextState.Summarizing => 3,
|
||||||
|
AssistantContextState.Finished => 4,
|
||||||
|
AssistantContextState.Error => 5,
|
||||||
|
_ => 5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record LiveIdentificationCheckpoint(
|
public sealed record LiveIdentificationCheckpoint(
|
||||||
IReadOnlyList<string> Speakers,
|
IReadOnlyList<string> Speakers,
|
||||||
string AttendeeSignature)
|
string AttendeeSignature)
|
||||||
|
|||||||
@@ -18,7 +18,14 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
|||||||
|
|
||||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var folder = GetTemporaryRecordingsFolder();
|
return CreateSessionAsync(options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IRecordedAudioSink> CreateSessionAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var folder = GetTemporaryRecordingsFolder(options);
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav");
|
var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav");
|
||||||
@@ -30,7 +37,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
|||||||
|
|
||||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var folder = GetTemporaryRecordingsFolder();
|
var folder = GetTemporaryRecordingsFolder(options);
|
||||||
if (!Directory.Exists(folder))
|
if (!Directory.Exists(folder))
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@@ -45,7 +52,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetTemporaryRecordingsFolder()
|
private static string GetTemporaryRecordingsFolder(MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
return VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
|
return VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await CreateSessionAsync(options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TranscriptSession> CreateSessionAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
|
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-transcript.md";
|
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-transcript.md";
|
||||||
var path = Path.Combine(folder, fileName);
|
var path = Path.Combine(folder, fileName);
|
||||||
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
|
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
|
||||||
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);
|
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);
|
||||||
|
|||||||
@@ -52,7 +52,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var segments = await diarizationClient.DiarizeAsync(tempPath, cancellationToken);
|
var segments = await DiarizeWithTimeoutAsync(tempPath, cancellationToken);
|
||||||
|
if (segments.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Speaker identity matching {DiarizedSpeaker} skipped because diarization returned no segments",
|
||||||
|
request.DiarizedSpeaker);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Speaker identity matching {DiarizedSpeaker} received {SegmentCount} diarized segment(s)",
|
"Speaker identity matching {DiarizedSpeaker} received {SegmentCount} diarized segment(s)",
|
||||||
request.DiarizedSpeaker,
|
request.DiarizedSpeaker,
|
||||||
@@ -95,6 +103,31 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizeWithTimeoutAsync(
|
||||||
|
string wavPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var matchTimeout = options.MatchTimeout;
|
||||||
|
if (matchTimeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return await diarizationClient.DiarizeAsync(wavPath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var timeout = new CancellationTokenSource(matchTimeout);
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await diarizationClient.DiarizeAsync(wavPath, linked.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Speaker identity diarization timed out after {Timeout}",
|
||||||
|
matchTimeout);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
|
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
|
||||||
{
|
{
|
||||||
using var firstReader = OpenFirstReadableWave(request);
|
using var firstReader = OpenFirstReadableWave(request);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ public sealed record SpeakerIdentificationRequest(
|
|||||||
string AudioPath,
|
string AudioPath,
|
||||||
MeetingNote MeetingNote,
|
MeetingNote MeetingNote,
|
||||||
IReadOnlyList<TranscriptionSegment> Segments,
|
IReadOnlyList<TranscriptionSegment> Segments,
|
||||||
IReadOnlyList<SpeakerAudioSample>? Samples = null);
|
IReadOnlyList<SpeakerAudioSample>? Samples = null,
|
||||||
|
IReadOnlyDictionary<string, string>? KnownSpeakerMappings = null);
|
||||||
|
|
||||||
public sealed record SpeakerAudioSample(
|
public sealed record SpeakerAudioSample(
|
||||||
string Speaker,
|
string Speaker,
|
||||||
@@ -32,7 +33,7 @@ public sealed record SpeakerIdentityMatchRequest(
|
|||||||
public sealed record SpeakerIdentityMatchCandidate(
|
public sealed record SpeakerIdentityMatchCandidate(
|
||||||
int IdentityId,
|
int IdentityId,
|
||||||
string? CanonicalName,
|
string? CanonicalName,
|
||||||
int TranscriptionCount,
|
int ReferenceCount,
|
||||||
IReadOnlyList<byte[]> Snippets);
|
IReadOnlyList<byte[]> Snippets);
|
||||||
|
|
||||||
public sealed record SpeakerIdentityMatch(int IdentityId);
|
public sealed record SpeakerIdentityMatch(int IdentityId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace MeetingAssistant.Speakers;
|
namespace MeetingAssistant.Speakers;
|
||||||
|
|
||||||
@@ -8,8 +9,6 @@ public sealed class SpeakerIdentity
|
|||||||
|
|
||||||
public string? CanonicalName { get; set; }
|
public string? CanonicalName { get; set; }
|
||||||
|
|
||||||
public int TranscriptionCount { get; set; }
|
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; }
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAt { get; set; }
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
@@ -20,6 +19,11 @@ public sealed class SpeakerIdentity
|
|||||||
|
|
||||||
public List<SpeakerSnippet> Snippets { get; set; } = [];
|
public List<SpeakerSnippet> Snippets { get; set; } = [];
|
||||||
|
|
||||||
|
public List<SpeakerIdentityReference> References { get; set; } = [];
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public int ReferenceCount => References.Count;
|
||||||
|
|
||||||
public string? GetDisplayName()
|
public string? GetDisplayName()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(CanonicalName))
|
if (!string.IsNullOrWhiteSpace(CanonicalName))
|
||||||
@@ -35,6 +39,65 @@ public sealed class SpeakerIdentity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class SpeakerIdentityReference
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int SpeakerIdentityId { get; set; }
|
||||||
|
|
||||||
|
public SpeakerIdentity? SpeakerIdentity { get; set; }
|
||||||
|
|
||||||
|
public string MeetingNotePath { get; set; } = "";
|
||||||
|
|
||||||
|
public string TranscriptPath { get; set; } = "";
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SpeakerIdentityReferences
|
||||||
|
{
|
||||||
|
public static SpeakerIdentityReference Create(
|
||||||
|
string meetingNotePath,
|
||||||
|
string transcriptPath,
|
||||||
|
DateTimeOffset createdAt)
|
||||||
|
{
|
||||||
|
return new SpeakerIdentityReference
|
||||||
|
{
|
||||||
|
MeetingNotePath = meetingNotePath,
|
||||||
|
TranscriptPath = transcriptPath,
|
||||||
|
CreatedAt = createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AddIfMissing(
|
||||||
|
SpeakerIdentity identity,
|
||||||
|
SpeakerIdentityReference reference,
|
||||||
|
DateTimeOffset? createdAt = null)
|
||||||
|
{
|
||||||
|
if (IsEmpty(reference) || identity.References.Any(existing => IsSame(existing, reference)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
identity.References.Add(Create(
|
||||||
|
reference.MeetingNotePath,
|
||||||
|
reference.TranscriptPath,
|
||||||
|
createdAt ?? reference.CreatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmpty(SpeakerIdentityReference reference)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(reference.MeetingNotePath) &&
|
||||||
|
string.IsNullOrWhiteSpace(reference.TranscriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSame(SpeakerIdentityReference first, SpeakerIdentityReference second)
|
||||||
|
{
|
||||||
|
return string.Equals(first.MeetingNotePath, second.MeetingNotePath, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(first.TranscriptPath, second.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class SpeakerCandidateName
|
public sealed class SpeakerCandidateName
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
@@ -85,10 +148,15 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
|||||||
|
|
||||||
public DbSet<SpeakerSnippet> SpeakerSnippets => Set<SpeakerSnippet>();
|
public DbSet<SpeakerSnippet> SpeakerSnippets => Set<SpeakerSnippet>();
|
||||||
|
|
||||||
|
public DbSet<SpeakerIdentityReference> SpeakerIdentityReferences => Set<SpeakerIdentityReference>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<SpeakerIdentity>(entity =>
|
modelBuilder.Entity<SpeakerIdentity>(entity =>
|
||||||
{
|
{
|
||||||
|
entity.Property<int>("TranscriptionCount")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
entity.HasMany(identity => identity.CandidateNames)
|
entity.HasMany(identity => identity.CandidateNames)
|
||||||
.WithOne(candidate => candidate.SpeakerIdentity)
|
.WithOne(candidate => candidate.SpeakerIdentity)
|
||||||
.HasForeignKey(candidate => candidate.SpeakerIdentityId)
|
.HasForeignKey(candidate => candidate.SpeakerIdentityId)
|
||||||
@@ -103,6 +171,11 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
|||||||
.WithOne(snippet => snippet.SpeakerIdentity)
|
.WithOne(snippet => snippet.SpeakerIdentity)
|
||||||
.HasForeignKey(snippet => snippet.SpeakerIdentityId)
|
.HasForeignKey(snippet => snippet.SpeakerIdentityId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasMany(identity => identity.References)
|
||||||
|
.WithOne(reference => reference.SpeakerIdentity)
|
||||||
|
.HasForeignKey(reference => reference.SpeakerIdentityId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SpeakerCandidateName>()
|
modelBuilder.Entity<SpeakerCandidateName>()
|
||||||
@@ -112,5 +185,9 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
|||||||
modelBuilder.Entity<SpeakerAlias>()
|
modelBuilder.Entity<SpeakerAlias>()
|
||||||
.HasIndex(alias => new { alias.SpeakerIdentityId, alias.Name })
|
.HasIndex(alias => new { alias.SpeakerIdentityId, alias.Name })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<SpeakerIdentityReference>()
|
||||||
|
.HasIndex(reference => new { reference.SpeakerIdentityId, reference.MeetingNotePath, reference.TranscriptPath })
|
||||||
|
.IsUnique();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MeetingAssistant.MeetingNotes;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Speakers;
|
||||||
|
|
||||||
|
public interface ISpeakerIdentityAttendeeCanonicalizer
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||||
|
IReadOnlyList<string> attendees,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
|
||||||
|
|
||||||
|
public SpeakerIdentityAttendeeCanonicalizer(IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory)
|
||||||
|
{
|
||||||
|
this.dbContextFactory = dbContextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||||
|
IReadOnlyList<string> attendees,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var attendeeEntries = attendees
|
||||||
|
.Select(attendee => new
|
||||||
|
{
|
||||||
|
Raw = attendee.Trim(),
|
||||||
|
DisplayName = NormalizeName(attendee)
|
||||||
|
})
|
||||||
|
.Where(entry => !string.IsNullOrWhiteSpace(entry.Raw) && !string.IsNullOrWhiteSpace(entry.DisplayName))
|
||||||
|
.ToList();
|
||||||
|
if (attendeeEntries.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||||
|
|
||||||
|
var identities = await context.SpeakerIdentities
|
||||||
|
.Include(identity => identity.Aliases)
|
||||||
|
.Include(identity => identity.References)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var identitiesByAcceptedName = identities
|
||||||
|
.OrderBy(identity => string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||||
|
.ThenByDescending(identity => identity.ReferenceCount)
|
||||||
|
.ThenByDescending(identity => identity.UpdatedAt)
|
||||||
|
.ThenBy(identity => identity.Id)
|
||||||
|
.SelectMany(identity => GetExactAcceptedNames(identity).Select(name => new { Name = name, Identity = identity }))
|
||||||
|
.GroupBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().Identity, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var seenIdentityIds = new HashSet<int>();
|
||||||
|
foreach (var attendee in attendeeEntries)
|
||||||
|
{
|
||||||
|
if (identitiesByAcceptedName.TryGetValue(attendee.DisplayName!, out var identity))
|
||||||
|
{
|
||||||
|
var displayName = identity.GetDisplayName();
|
||||||
|
if (string.IsNullOrWhiteSpace(displayName) || !seenIdentityIds.Add(identity.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenNames.Add(displayName))
|
||||||
|
{
|
||||||
|
result.Add(displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenNames.Add(attendee.Raw))
|
||||||
|
{
|
||||||
|
result.Add(attendee.Raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> GetExactAcceptedNames(SpeakerIdentity identity)
|
||||||
|
{
|
||||||
|
return new[] { identity.CanonicalName }
|
||||||
|
.Concat(identity.Aliases.Select(alias => alias.Name))
|
||||||
|
.Select(NormalizeName)
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Select(name => name!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeName(string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeetingAttendeeNames.NormalizeDisplayName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PassthroughSpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||||
|
{
|
||||||
|
public static PassthroughSpeakerIdentityAttendeeCanonicalizer Instance { get; } = new();
|
||||||
|
|
||||||
|
private PassthroughSpeakerIdentityAttendeeCanonicalizer()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||||
|
IReadOnlyList<string> attendees,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var distinctAttendees = attendees
|
||||||
|
.Select(attendee => attendee.Trim())
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
return Task.FromResult<IReadOnlyList<string>>(distinctAttendees);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,8 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
|||||||
.Include(identity => identity.Aliases)
|
.Include(identity => identity.Aliases)
|
||||||
.Include(identity => identity.CandidateNames)
|
.Include(identity => identity.CandidateNames)
|
||||||
.Include(identity => identity.Snippets)
|
.Include(identity => identity.Snippets)
|
||||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
.Include(identity => identity.References)
|
||||||
|
.OrderByDescending(identity => identity.References.Count)
|
||||||
.ThenBy(identity => identity.Id)
|
.ThenBy(identity => identity.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -107,7 +108,14 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var targetName = target.GetDisplayName() ?? $"identity-{target.Id}";
|
||||||
|
var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}";
|
||||||
MergeIdentities(target, source);
|
MergeIdentities(target, source);
|
||||||
|
await SpeakerIdentityTranscriptAudit.AppendMergedAsync(
|
||||||
|
target.References,
|
||||||
|
targetName,
|
||||||
|
sourceName,
|
||||||
|
cancellationToken);
|
||||||
context.SpeakerIdentities.Remove(source);
|
context.SpeakerIdentities.Remove(source);
|
||||||
identities.Remove(source);
|
identities.Remove(source);
|
||||||
mergedPairs++;
|
mergedPairs++;
|
||||||
@@ -138,7 +146,7 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
|||||||
candidates.Select(identity => new SpeakerIdentityMatchCandidate(
|
candidates.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||||
identity.Id,
|
identity.Id,
|
||||||
identity.GetDisplayName(),
|
identity.GetDisplayName(),
|
||||||
identity.TranscriptionCount,
|
identity.ReferenceCount,
|
||||||
identity.Snippets
|
identity.Snippets
|
||||||
.OrderBy(snippet => snippet.CreatedAt)
|
.OrderBy(snippet => snippet.CreatedAt)
|
||||||
.Select(snippet => snippet.WavBytes)
|
.Select(snippet => snippet.WavBytes)
|
||||||
@@ -166,8 +174,12 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
|||||||
AddAlias(target, candidate.Name);
|
AddAlias(target, candidate.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
target.TranscriptionCount += source.TranscriptionCount;
|
|
||||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var reference in source.References)
|
||||||
|
{
|
||||||
|
SpeakerIdentityReferences.AddIfMissing(target, reference);
|
||||||
|
}
|
||||||
|
|
||||||
var retainedSnippets = target.Snippets
|
var retainedSnippets = target.Snippets
|
||||||
.Concat(source.Snippets)
|
.Concat(source.Snippets)
|
||||||
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
|
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ internal static class SpeakerIdentitySchema
|
|||||||
ON "SpeakerAliases" ("SpeakerIdentityId", "Name");
|
ON "SpeakerAliases" ("SpeakerIdentityId", "Name");
|
||||||
""",
|
""",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
await context.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "SpeakerIdentityReferences" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentityReferences" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"SpeakerIdentityId" INTEGER NOT NULL,
|
||||||
|
"MeetingNotePath" TEXT NOT NULL,
|
||||||
|
"TranscriptPath" TEXT NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_SpeakerIdentityReferences_SpeakerIdentities_SpeakerIdentityId"
|
||||||
|
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
cancellationToken);
|
||||||
|
await context.Database.ExecuteSqlRawAsync(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SpeakerIdentityReferences_SpeakerIdentityId_MeetingNotePath_TranscriptPath"
|
||||||
|
ON "SpeakerIdentityReferences" ("SpeakerIdentityId", "MeetingNotePath", "TranscriptPath");
|
||||||
|
""",
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task EnsureSpeakerIdentityTimestampColumnsAsync(
|
private static async Task EnsureSpeakerIdentityTimestampColumnsAsync(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using MeetingAssistant.MeetingNotes;
|
||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
@@ -54,9 +55,31 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||||
|
|
||||||
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
|
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
|
||||||
|
var meetingReference = CreateReference(request.MeetingNote, DateTimeOffset.UtcNow);
|
||||||
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
|
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
|
||||||
|
var knownSpeakerMappings = request.KnownSpeakerMappings ??
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var knownDiarizedSpeakers = knownSpeakerMappings.Keys
|
||||||
|
.Where(speaker => !string.IsNullOrWhiteSpace(speaker))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var alreadyIdentifiedNames = knownSpeakerMappings.Values
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.Select(name => name.Trim())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var speaker in request.Segments
|
||||||
|
.Select(segment => segment.Speaker)
|
||||||
|
.Where(speaker => !string.IsNullOrWhiteSpace(speaker) && !IsDiarizedSpeakerLabel(speaker)))
|
||||||
|
{
|
||||||
|
alreadyIdentifiedNames.Add(speaker.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var name in alreadyIdentifiedNames)
|
||||||
|
{
|
||||||
|
matchedAcceptedNames.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
|
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
|
||||||
var samplesBySpeaker = request.Samples?
|
var samplesBySpeaker = request.Samples?
|
||||||
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
|
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
|
||||||
@@ -73,6 +96,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
.OrderBy(group => group.Min(segment => segment.Start)))
|
.OrderBy(group => group.Min(segment => segment.Start)))
|
||||||
{
|
{
|
||||||
var speaker = group.Key;
|
var speaker = group.Key;
|
||||||
|
if (knownDiarizedSpeakers.Contains(speaker) || alreadyIdentifiedNames.Contains(speaker))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (speakerMappings.ContainsKey(speaker))
|
if (speakerMappings.ContainsKey(speaker))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -90,7 +118,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var match = await FindMatchAsync(context, attendees, speaker, snippet, cancellationToken);
|
var match = await FindMatchAsync(
|
||||||
|
context,
|
||||||
|
attendees,
|
||||||
|
alreadyIdentifiedNames,
|
||||||
|
speaker,
|
||||||
|
snippet,
|
||||||
|
cancellationToken);
|
||||||
if (match is null)
|
if (match is null)
|
||||||
{
|
{
|
||||||
unmatchedSpeakers.Add((speaker, snippet));
|
unmatchedSpeakers.Add((speaker, snippet));
|
||||||
@@ -106,7 +140,19 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
|
|
||||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||||
{
|
{
|
||||||
|
var previousCanonicalName = identity.CanonicalName;
|
||||||
|
AddMeetingReference(identity, meetingReference);
|
||||||
UpdateMatchedIdentity(identity, attendees, snippet);
|
UpdateMatchedIdentity(identity, attendees, snippet);
|
||||||
|
if (string.IsNullOrWhiteSpace(previousCanonicalName) &&
|
||||||
|
!string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||||
|
{
|
||||||
|
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||||
|
identity.References,
|
||||||
|
speaker,
|
||||||
|
identity.CanonicalName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var acceptedName in GetAcceptedNames(identity))
|
foreach (var acceptedName in GetAcceptedNames(identity))
|
||||||
{
|
{
|
||||||
matchedAcceptedNames.Add(acceptedName);
|
matchedAcceptedNames.Add(acceptedName);
|
||||||
@@ -117,6 +163,12 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
if (!string.IsNullOrWhiteSpace(speakerName))
|
if (!string.IsNullOrWhiteSpace(speakerName))
|
||||||
{
|
{
|
||||||
speakerMappings[speaker] = speakerName;
|
speakerMappings[speaker] = speakerName;
|
||||||
|
alreadyIdentifiedNames.Add(speakerName);
|
||||||
|
foreach (var acceptedName in GetAcceptedNames(identity))
|
||||||
|
{
|
||||||
|
alreadyIdentifiedNames.Add(acceptedName);
|
||||||
|
}
|
||||||
|
|
||||||
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
|
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
|
||||||
speakerName,
|
speakerName,
|
||||||
GetAcceptedNames(identity).ToList()));
|
GetAcceptedNames(identity).ToList()));
|
||||||
@@ -125,7 +177,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
|
|
||||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||||
{
|
{
|
||||||
await LearnUnmatchedSpeakersAsync(context, attendees, matchedAcceptedNames, unmatchedSpeakers, cancellationToken);
|
await LearnUnmatchedSpeakersAsync(
|
||||||
|
context,
|
||||||
|
attendees,
|
||||||
|
matchedAcceptedNames,
|
||||||
|
unmatchedSpeakers,
|
||||||
|
meetingReference,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken);
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
@@ -141,6 +199,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
|
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
|
||||||
SpeakerIdentityDbContext context,
|
SpeakerIdentityDbContext context,
|
||||||
IReadOnlyList<string> attendees,
|
IReadOnlyList<string> attendees,
|
||||||
|
IReadOnlySet<string> alreadyIdentifiedNames,
|
||||||
string speaker,
|
string speaker,
|
||||||
byte[] snippet,
|
byte[] snippet,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -150,7 +209,8 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
var identities = await context.SpeakerIdentities
|
var identities = await context.SpeakerIdentities
|
||||||
.Include(identity => identity.Snippets)
|
.Include(identity => identity.Snippets)
|
||||||
.Include(identity => identity.Aliases)
|
.Include(identity => identity.Aliases)
|
||||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
.Include(identity => identity.References)
|
||||||
|
.OrderByDescending(identity => identity.References.Count)
|
||||||
.ThenBy(identity => identity.Id)
|
.ThenBy(identity => identity.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
identities = identities
|
identities = identities
|
||||||
@@ -161,8 +221,9 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
IsActive = identity.UpdatedAt >= activeCutoff
|
IsActive = identity.UpdatedAt >= activeCutoff
|
||||||
})
|
})
|
||||||
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
|
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
|
||||||
|
.Where(candidate => !MatchesAcceptedNames(candidate.Identity, alreadyIdentifiedNames))
|
||||||
.OrderByDescending(candidate => candidate.IsAttendee)
|
.OrderByDescending(candidate => candidate.IsAttendee)
|
||||||
.ThenByDescending(candidate => candidate.Identity.TranscriptionCount)
|
.ThenByDescending(candidate => candidate.Identity.ReferenceCount)
|
||||||
.ThenBy(candidate => candidate.Identity.Id)
|
.ThenBy(candidate => candidate.Identity.Id)
|
||||||
.Take(maxCandidates)
|
.Take(maxCandidates)
|
||||||
.Select(candidate => candidate.Identity)
|
.Select(candidate => candidate.Identity)
|
||||||
@@ -176,7 +237,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
batch.Select(identity => new SpeakerIdentityMatchCandidate(
|
batch.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||||
identity.Id,
|
identity.Id,
|
||||||
identity.CanonicalName,
|
identity.CanonicalName,
|
||||||
identity.TranscriptionCount,
|
identity.ReferenceCount,
|
||||||
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
|
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
|
||||||
.ToList());
|
.ToList());
|
||||||
var match = await matcher.MatchAsync(request, cancellationToken);
|
var match = await matcher.MatchAsync(request, cancellationToken);
|
||||||
@@ -189,6 +250,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MatchesAcceptedNames(
|
||||||
|
SpeakerIdentity identity,
|
||||||
|
IReadOnlySet<string> names)
|
||||||
|
{
|
||||||
|
return GetAcceptedNames(identity).Any(names.Contains);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MatchesAttendees(
|
private static bool MatchesAttendees(
|
||||||
SpeakerIdentity identity,
|
SpeakerIdentity identity,
|
||||||
IReadOnlyList<string> attendees)
|
IReadOnlyList<string> attendees)
|
||||||
@@ -206,6 +274,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
.Include(identity => identity.CandidateNames)
|
.Include(identity => identity.CandidateNames)
|
||||||
.Include(identity => identity.Aliases)
|
.Include(identity => identity.Aliases)
|
||||||
.Include(identity => identity.Snippets)
|
.Include(identity => identity.Snippets)
|
||||||
|
.Include(identity => identity.References)
|
||||||
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
|
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +283,6 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
IReadOnlyList<string> attendees,
|
IReadOnlyList<string> attendees,
|
||||||
byte[] snippet)
|
byte[] snippet)
|
||||||
{
|
{
|
||||||
identity.TranscriptionCount++;
|
|
||||||
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
|
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
|
||||||
@@ -265,6 +333,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
IReadOnlyList<string> attendees,
|
IReadOnlyList<string> attendees,
|
||||||
IEnumerable<string> matchedCanonicalNames,
|
IEnumerable<string> matchedCanonicalNames,
|
||||||
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
|
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
|
||||||
|
SpeakerIdentityReference meetingReference,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var remainingCandidates = attendees
|
var remainingCandidates = attendees
|
||||||
@@ -276,11 +345,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (_, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
context.SpeakerIdentities.Add(new SpeakerIdentity
|
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
|
||||||
|
var identity = new SpeakerIdentity
|
||||||
{
|
{
|
||||||
|
CanonicalName = canonicalName,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
CandidateNames = remainingCandidates
|
CandidateNames = remainingCandidates
|
||||||
@@ -293,8 +364,24 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
WavBytes = snippet,
|
WavBytes = snippet,
|
||||||
CreatedAt = now
|
CreatedAt = now
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
References =
|
||||||
|
[
|
||||||
|
SpeakerIdentityReferences.Create(
|
||||||
|
meetingReference.MeetingNotePath,
|
||||||
|
meetingReference.TranscriptPath,
|
||||||
|
now)
|
||||||
]
|
]
|
||||||
});
|
};
|
||||||
|
context.SpeakerIdentities.Add(identity);
|
||||||
|
if (!string.IsNullOrWhiteSpace(canonicalName))
|
||||||
|
{
|
||||||
|
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||||
|
identity.References,
|
||||||
|
speaker,
|
||||||
|
canonicalName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
@@ -366,9 +453,30 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|||||||
|
|
||||||
private static string NormalizeAttendee(string attendee)
|
private static string NormalizeAttendee(string attendee)
|
||||||
{
|
{
|
||||||
var trimmed = attendee.Trim();
|
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
||||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
}
|
||||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
|
||||||
|
private static bool IsDiarizedSpeakerLabel(string speaker)
|
||||||
|
{
|
||||||
|
var normalized = speaker.Trim();
|
||||||
|
return normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("Guest", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("Speaker", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpeakerIdentityReference CreateReference(MeetingNote meetingNote, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
return SpeakerIdentityReferences.Create(
|
||||||
|
meetingNote.Path,
|
||||||
|
meetingNote.Frontmatter.Transcript,
|
||||||
|
timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMeetingReference(
|
||||||
|
SpeakerIdentity identity,
|
||||||
|
SpeakerIdentityReference reference)
|
||||||
|
{
|
||||||
|
SpeakerIdentityReferences.AddIfMissing(identity, reference, DateTimeOffset.UtcNow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SpeakerIdentityProcessingMode
|
private enum SpeakerIdentityProcessingMode
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
namespace MeetingAssistant.Speakers;
|
||||||
|
|
||||||
|
internal static class SpeakerIdentityTranscriptAudit
|
||||||
|
{
|
||||||
|
public static Task AppendIdentifiedAsync(
|
||||||
|
IEnumerable<SpeakerIdentityReference> references,
|
||||||
|
string speakerLabel,
|
||||||
|
string name,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return AppendAsync(
|
||||||
|
references,
|
||||||
|
$"{DateTimeOffset.Now:yyyy-MM-dd} {speakerLabel} was identified as {name}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task AppendMergedAsync(
|
||||||
|
IEnumerable<SpeakerIdentityReference> references,
|
||||||
|
string name1,
|
||||||
|
string name2,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return AppendAsync(
|
||||||
|
references,
|
||||||
|
$"{DateTimeOffset.Now:yyyy-MM-dd} {name1} and {name2} were merged",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AppendAsync(
|
||||||
|
IEnumerable<SpeakerIdentityReference> references,
|
||||||
|
string line,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var transcriptPath in references
|
||||||
|
.Select(reference => reference.TranscriptPath)
|
||||||
|
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (!File.Exists(transcriptPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||||
|
if (existing.Contains(line, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var separator = existing.EndsWith(Environment.NewLine, StringComparison.Ordinal)
|
||||||
|
? ""
|
||||||
|
: Environment.NewLine;
|
||||||
|
await File.AppendAllTextAsync(
|
||||||
|
transcriptPath,
|
||||||
|
$"{separator}{line}{Environment.NewLine}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using MeetingAssistant.MeetingNotes;
|
||||||
|
using YamlDotNet.Serialization;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Summary;
|
||||||
|
|
||||||
|
public sealed record BoundMeetingProject(string Name, string Path);
|
||||||
|
|
||||||
|
public sealed class BoundMeetingProjectResolver
|
||||||
|
{
|
||||||
|
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||||
|
.IgnoreUnmatchedProperties()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private readonly MeetingAssistantOptions options;
|
||||||
|
|
||||||
|
public BoundMeetingProjectResolver(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||||
|
|
||||||
|
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ProjectsRoot))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||||
|
if (projectNames.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Directory.EnumerateDirectories(ProjectsRoot)
|
||||||
|
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||||
|
.Where(project => projectNames.Contains(project.Name))
|
||||||
|
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||||
|
string meetingNotePath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(meetingNotePath))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
|
||||||
|
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||||
|
if (!document.HasFrontmatter)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
|
||||||
|
?? new ProjectFrontmatter();
|
||||||
|
return (frontmatter.Projects ?? [])
|
||||||
|
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||||
|
.Select(project => project.Trim())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ProjectFrontmatter
|
||||||
|
{
|
||||||
|
[YamlMember(Alias = "projects")]
|
||||||
|
public List<string>? Projects { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,12 @@ public interface IMeetingSummaryInstructionBuilder
|
|||||||
Task<string> BuildAsync(
|
Task<string> BuildAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<string> BuildAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return BuildAsync(artifacts, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ namespace MeetingAssistant.Summary;
|
|||||||
public interface IMeetingSummaryPipeline
|
public interface IMeetingSummaryPipeline
|
||||||
{
|
{
|
||||||
Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken);
|
Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<MeetingSummaryRunResult> RunAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return RunAsync(artifacts, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record MeetingSummaryRunResult(
|
public sealed record MeetingSummaryRunResult(
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ public interface IMeetingSummaryArtifactResolver
|
|||||||
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||||
string summaryPath,
|
string summaryPath,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||||
|
string summaryPath,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
|
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
|
||||||
@@ -27,7 +35,15 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
|||||||
string summaryPath,
|
string summaryPath,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath);
|
return await ResolveBySummaryPathAsync(summaryPath, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||||
|
string summaryPath,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath, options);
|
||||||
if (requestedSummaryPath is null)
|
if (requestedSummaryPath is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -58,7 +74,9 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? ResolveRequestedSummaryPath(string summaryPath)
|
private static string? ResolveRequestedSummaryPath(
|
||||||
|
string summaryPath,
|
||||||
|
MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(summaryPath))
|
if (string.IsNullOrWhiteSpace(summaryPath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ public interface IMeetingSummaryFailureWriter
|
|||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
Exception exception,
|
Exception exception,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<MeetingSummaryRunResult> WriteAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
Exception exception,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return WriteAsync(artifacts, exception, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
||||||
@@ -29,6 +38,15 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
|||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
Exception exception,
|
Exception exception,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await WriteAsync(artifacts, exception, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MeetingSummaryRunResult> WriteAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
Exception exception,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||||
var meetingNote = File.Exists(artifacts.MeetingNotePath)
|
var meetingNote = File.Exists(artifacts.MeetingNotePath)
|
||||||
@@ -43,7 +61,7 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
|||||||
artifacts.SummaryPath,
|
artifacts.SummaryPath,
|
||||||
MeetingArtifactFrontmatterRenderer.Render(
|
MeetingArtifactFrontmatterRenderer.Render(
|
||||||
frontmatter,
|
frontmatter,
|
||||||
RenderFailureMarkdown(artifacts, exception)),
|
RenderFailureMarkdown(artifacts, exception, options)),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return new MeetingSummaryRunResult(
|
return new MeetingSummaryRunResult(
|
||||||
@@ -53,7 +71,10 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
|||||||
Error: exception.Message);
|
Error: exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string RenderFailureMarkdown(MeetingSessionArtifacts artifacts, Exception exception)
|
private static string RenderFailureMarkdown(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
Exception exception,
|
||||||
|
MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
var builder = new StringBuilder();
|
var builder = new StringBuilder();
|
||||||
builder.AppendLine("# Meeting Summary");
|
builder.AppendLine("# Meeting Summary");
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using MeetingAssistant.MeetingNotes;
|
using MeetingAssistant.MeetingNotes;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using YamlDotNet.Serialization;
|
|
||||||
|
|
||||||
namespace MeetingAssistant.Summary;
|
namespace MeetingAssistant.Summary;
|
||||||
|
|
||||||
@@ -21,25 +20,31 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
|||||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
|
||||||
.IgnoreUnmatchedProperties()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly MeetingAssistantOptions options;
|
||||||
|
private readonly BoundMeetingProjectResolver projectResolver;
|
||||||
|
|
||||||
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
|
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
|
||||||
{
|
{
|
||||||
this.options = options.Value;
|
this.options = options.Value;
|
||||||
|
projectResolver = new BoundMeetingProjectResolver(this.options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> BuildAsync(
|
public async Task<string> BuildAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await BuildAsync(artifacts, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> BuildAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||||
? DefaultInitialPrompt
|
? DefaultInitialPrompt
|
||||||
: options.Agent.InitialPrompt.Trim();
|
: options.Agent.InitialPrompt.Trim();
|
||||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, cancellationToken);
|
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
|
||||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||||
? instructions
|
? instructions
|
||||||
: instructions.TrimEnd() + "\n\n" + projectInstructions;
|
: instructions.TrimEnd() + "\n\n" + projectInstructions;
|
||||||
@@ -47,9 +52,10 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
|||||||
|
|
||||||
private async Task<string> BuildProjectInstructionsAsync(
|
private async Task<string> BuildProjectInstructionsAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, cancellationToken);
|
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
|
||||||
if (projects.Count == 0)
|
if (projects.Count == 0)
|
||||||
{
|
{
|
||||||
return "";
|
return "";
|
||||||
@@ -62,30 +68,16 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
|||||||
|
|
||||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
var resolver = ReferenceEquals(options, this.options)
|
||||||
if (projectNames.Count == 0)
|
? projectResolver
|
||||||
{
|
: new BoundMeetingProjectResolver(options);
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectsRoot = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
|
||||||
if (!Directory.Exists(projectsRoot))
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var projects = new List<ProjectInstructions>();
|
var projects = new List<ProjectInstructions>();
|
||||||
foreach (var projectDirectory in Directory.EnumerateDirectories(projectsRoot).Order(StringComparer.OrdinalIgnoreCase))
|
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
|
||||||
{
|
{
|
||||||
var projectName = Path.GetFileName(projectDirectory);
|
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||||
if (!projectNames.Contains(projectName))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var agentsPath = Path.Combine(projectDirectory, "AGENTS.md");
|
|
||||||
if (!File.Exists(agentsPath))
|
if (!File.Exists(agentsPath))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -94,42 +86,12 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
|||||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||||
if (!string.IsNullOrWhiteSpace(content))
|
if (!string.IsNullOrWhiteSpace(content))
|
||||||
{
|
{
|
||||||
projects.Add(new ProjectInstructions(projectName, content));
|
projects.Add(new ProjectInstructions(project.Name, content));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return projects;
|
return projects;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
|
||||||
string meetingNotePath,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!File.Exists(meetingNotePath))
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
|
|
||||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
||||||
if (!document.HasFrontmatter)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
|
|
||||||
?? new ProjectFrontmatter();
|
|
||||||
return (frontmatter.Projects ?? [])
|
|
||||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
||||||
.Select(project => project.Trim())
|
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||||
|
|
||||||
private sealed class ProjectFrontmatter
|
|
||||||
{
|
|
||||||
[YamlMember(Alias = "projects")]
|
|
||||||
public List<string>? Projects { get; set; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public sealed class MeetingSummaryTools
|
|||||||
private readonly MeetingSessionArtifacts artifacts;
|
private readonly MeetingSessionArtifacts artifacts;
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly MeetingAssistantOptions options;
|
||||||
private readonly IDictationWordStore? dictationWordStore;
|
private readonly IDictationWordStore? dictationWordStore;
|
||||||
|
private readonly SummaryAgentWriteAudit? writeAudit;
|
||||||
|
private readonly BoundMeetingProjectResolver projectResolver;
|
||||||
|
|
||||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||||
: this(artifacts, new MeetingAssistantOptions(), null)
|
: this(artifacts, new MeetingAssistantOptions(), null)
|
||||||
@@ -24,11 +26,14 @@ public sealed class MeetingSummaryTools
|
|||||||
public MeetingSummaryTools(
|
public MeetingSummaryTools(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
MeetingAssistantOptions options,
|
MeetingAssistantOptions options,
|
||||||
IDictationWordStore? dictationWordStore = null)
|
IDictationWordStore? dictationWordStore = null,
|
||||||
|
SummaryAgentWriteAudit? writeAudit = null)
|
||||||
{
|
{
|
||||||
this.artifacts = artifacts;
|
this.artifacts = artifacts;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.dictationWordStore = dictationWordStore;
|
this.dictationWordStore = dictationWordStore;
|
||||||
|
this.writeAudit = writeAudit;
|
||||||
|
projectResolver = new BoundMeetingProjectResolver(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> ReadTranscript(int? @from = null, int? to = null)
|
public Task<string> ReadTranscript(int? @from = null, int? to = null)
|
||||||
@@ -76,7 +81,16 @@ public sealed class MeetingSummaryTools
|
|||||||
return "Refused: word must not be empty.";
|
return "Refused: word must not be empty.";
|
||||||
}
|
}
|
||||||
|
|
||||||
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
|
if (writeAudit is not null)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string>? capturedWords = null;
|
||||||
|
await writeAudit.CaptureFileWriteAsync(
|
||||||
|
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
|
||||||
|
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
|
||||||
|
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
|
||||||
|
}
|
||||||
|
|
||||||
|
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
|
||||||
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +190,17 @@ public sealed class MeetingSummaryTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
||||||
await WriteProjectFileContentAsync(target.Path, content, editMode);
|
if (writeAudit is not null)
|
||||||
|
{
|
||||||
|
await writeAudit.CaptureFileWriteAsync(
|
||||||
|
target.Path,
|
||||||
|
() => WriteProjectFileContentAsync(target.Path, content, editMode));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await WriteProjectFileContentAsync(target.Path, content, editMode);
|
||||||
|
}
|
||||||
|
|
||||||
return $"{target.Project.Name}/{ToToolPath(path)}";
|
return $"{target.Project.Name}/{ToToolPath(path)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +261,7 @@ public sealed class MeetingSummaryTools
|
|||||||
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
|
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
|
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
|
||||||
{
|
{
|
||||||
var boundProjects = await GetBoundProjectsAsync();
|
var boundProjects = await GetBoundProjectsAsync();
|
||||||
if (projects is null || projects.Length == 0)
|
if (projects is null || projects.Length == 0)
|
||||||
@@ -253,7 +277,7 @@ public sealed class MeetingSummaryTools
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
|
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(project))
|
if (string.IsNullOrWhiteSpace(project))
|
||||||
{
|
{
|
||||||
@@ -297,7 +321,7 @@ public sealed class MeetingSummaryTools
|
|||||||
}
|
}
|
||||||
|
|
||||||
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
||||||
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
|
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
|
||||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
||||||
if (projectFolder is null)
|
if (projectFolder is null)
|
||||||
{
|
{
|
||||||
@@ -414,56 +438,19 @@ public sealed class MeetingSummaryTools
|
|||||||
return string.Join('\n', lines);
|
return string.Join('\n', lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<ProjectFolder>> GetBoundProjectsAsync()
|
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
|
||||||
{
|
{
|
||||||
var projectsRoot = GetProjectsRoot();
|
return projectResolver.GetBoundProjectsAsync(artifacts);
|
||||||
if (!Directory.Exists(projectsRoot))
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectNames = await ReadMeetingProjectNamesAsync();
|
|
||||||
if (projectNames.Count == 0)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Directory.EnumerateDirectories(projectsRoot)
|
|
||||||
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
|
|
||||||
.Where(project => projectNames.Contains(project.Name))
|
|
||||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
|
|
||||||
{
|
|
||||||
if (!File.Exists(artifacts.MeetingNotePath))
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
|
||||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
|
||||||
if (!document.HasFrontmatter)
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
|
|
||||||
return (note.Projects ?? [])
|
|
||||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
|
||||||
.Select(project => project.Trim())
|
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetProjectsRoot()
|
private string GetProjectsRoot()
|
||||||
{
|
{
|
||||||
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
return projectResolver.ProjectsRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<string?> RunRipgrepAsync(
|
private static async Task<string?> RunRipgrepAsync(
|
||||||
string projectsRoot,
|
string projectsRoot,
|
||||||
IReadOnlyList<ProjectFolder> projects,
|
IReadOnlyList<BoundMeetingProject> projects,
|
||||||
string keywords)
|
string keywords)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -528,7 +515,7 @@ public sealed class MeetingSummaryTools
|
|||||||
return string.Join('\n', matches);
|
return string.Join('\n', matches);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
|
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -584,9 +571,7 @@ public sealed class MeetingSummaryTools
|
|||||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record ProjectFolder(string Name, string Path);
|
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
|
||||||
|
|
||||||
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
|
|
||||||
|
|
||||||
private sealed record FileLineEditMode(
|
private sealed record FileLineEditMode(
|
||||||
FileLineEditKind Kind,
|
FileLineEditKind Kind,
|
||||||
@@ -621,12 +606,6 @@ public sealed class MeetingSummaryTools
|
|||||||
Insert
|
Insert
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ProjectFrontmatter
|
|
||||||
{
|
|
||||||
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
|
||||||
public List<string>? Projects { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class MeetingNoteFrontmatterYaml
|
private sealed class MeetingNoteFrontmatterYaml
|
||||||
{
|
{
|
||||||
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
||||||
|
|||||||
@@ -39,11 +39,20 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
|||||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||||
MeetingSessionArtifacts artifacts,
|
MeetingSessionArtifacts artifacts,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await RunAsync(artifacts, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||||
|
MeetingSessionArtifacts artifacts,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var agentOptions = options.Agent;
|
var agentOptions = options.Agent;
|
||||||
var key = ResolveApiKey(agentOptions);
|
var key = ResolveApiKey(agentOptions);
|
||||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore));
|
var writeAudit = new SummaryAgentWriteAudit(artifacts);
|
||||||
var instructions = await instructionBuilder.BuildAsync(artifacts, cancellationToken);
|
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit));
|
||||||
|
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||||
new Uri(agentOptions.Endpoint),
|
new Uri(agentOptions.Endpoint),
|
||||||
key,
|
key,
|
||||||
@@ -85,6 +94,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
|||||||
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
|
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
|
||||||
cancellationToken: cancellationToken);
|
cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
|
||||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
|
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
@@ -97,7 +107,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
|||||||
exception,
|
exception,
|
||||||
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
|
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
|
||||||
artifacts.SummaryPath);
|
artifacts.SummaryPath);
|
||||||
return await failureWriter.WriteAsync(artifacts, exception, cancellationToken);
|
var result = await failureWriter.WriteAsync(artifacts, exception, options, cancellationToken);
|
||||||
|
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using DiffPlex.DiffBuilder;
|
||||||
|
using DiffPlex.DiffBuilder.Model;
|
||||||
|
using MeetingAssistant.MeetingNotes;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Summary;
|
||||||
|
|
||||||
|
public sealed class SummaryAgentWriteAudit
|
||||||
|
{
|
||||||
|
private readonly MeetingSessionArtifacts artifacts;
|
||||||
|
private readonly List<FileChange> changes = [];
|
||||||
|
|
||||||
|
public SummaryAgentWriteAudit(MeetingSessionArtifacts artifacts)
|
||||||
|
{
|
||||||
|
this.artifacts = artifacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task CaptureFileWriteAsync(
|
||||||
|
string path,
|
||||||
|
Func<Task> writeOperation,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
var before = File.Exists(fullPath)
|
||||||
|
? await File.ReadAllTextAsync(fullPath, cancellationToken)
|
||||||
|
: "";
|
||||||
|
await writeOperation();
|
||||||
|
var after = File.Exists(fullPath)
|
||||||
|
? await File.ReadAllTextAsync(fullPath, cancellationToken)
|
||||||
|
: "";
|
||||||
|
Capture(fullPath, before, after, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Capture(
|
||||||
|
string path,
|
||||||
|
string before,
|
||||||
|
string after,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var fullPath = Path.GetFullPath(path);
|
||||||
|
if (IsOwnedArtifact(fullPath) || string.Equals(before, after, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var diffLines = CreateDiffLines(before, after);
|
||||||
|
if (diffLines.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.Add(new FileChange(fullPath, diffLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AppendToAssistantContextAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (changes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||||
|
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||||
|
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken)
|
||||||
|
: "";
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("## Summary Agent External File Changes");
|
||||||
|
builder.AppendLine();
|
||||||
|
foreach (var change in changes)
|
||||||
|
{
|
||||||
|
builder.AppendLine($"### {change.Path}");
|
||||||
|
builder.AppendLine();
|
||||||
|
builder.AppendLine("```diff");
|
||||||
|
foreach (var line in change.DiffLines)
|
||||||
|
{
|
||||||
|
builder.AppendLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AppendLine("```");
|
||||||
|
builder.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.AppendAllTextAsync(artifacts.AssistantContextPath, builder.ToString(), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsOwnedArtifact(string fullPath)
|
||||||
|
{
|
||||||
|
return IsSamePath(fullPath, artifacts.SummaryPath) ||
|
||||||
|
IsSamePath(fullPath, artifacts.AssistantContextPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsSamePath(string first, string second)
|
||||||
|
{
|
||||||
|
return string.Equals(
|
||||||
|
Path.GetFullPath(first),
|
||||||
|
Path.GetFullPath(second),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> CreateDiffLines(string before, string after)
|
||||||
|
{
|
||||||
|
var model = InlineDiffBuilder.Diff(before, after);
|
||||||
|
return model.Lines
|
||||||
|
.Where(line => line.Type is ChangeType.Deleted or ChangeType.Inserted)
|
||||||
|
.Select(line => $"{(line.Type == ChangeType.Inserted ? "+" : "-")} {line.Text}")
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record FileChange(string Path, IReadOnlyList<string> DiffLines);
|
||||||
|
}
|
||||||
@@ -12,20 +12,18 @@ public sealed class AsrDiagnosticService
|
|||||||
this.pipelineFactory = pipelineFactory;
|
this.pipelineFactory = pipelineFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
|
public Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
return TranscribeWavAsync(path, launchProfileName: null, cancellationToken);
|
||||||
{
|
}
|
||||||
throw new ArgumentException("A WAV file path is required.", nameof(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));
|
public async Task<AsrDiagnosticResult> TranscribeWavAsync(
|
||||||
if (!File.Exists(fullPath))
|
string path,
|
||||||
{
|
string? launchProfileName,
|
||||||
throw new FileNotFoundException($"WAV file was not found at '{fullPath}'.", fullPath);
|
CancellationToken cancellationToken)
|
||||||
}
|
{
|
||||||
|
var fullPath = ResolveExistingWavPath(path);
|
||||||
await using var pipeline = pipelineFactory.Create();
|
await using var pipeline = pipelineFactory.Create(launchProfileName);
|
||||||
await pipeline.InitializeAsync(cancellationToken);
|
await pipeline.InitializeAsync(cancellationToken);
|
||||||
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
||||||
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
||||||
@@ -39,7 +37,17 @@ public sealed class AsrDiagnosticService
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var fullPath = ResolveExistingWavPath(path);
|
var fullPath = ResolveExistingWavPath(path);
|
||||||
await using var pipeline = pipelineFactory.Create();
|
return await DiarizeWavAsync(fullPath, options, launchProfileName: null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AsrDiagnosticResult> DiarizeWavAsync(
|
||||||
|
string path,
|
||||||
|
SpeechRecognitionPipelineOptions options,
|
||||||
|
string? launchProfileName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fullPath = ResolveExistingWavPath(path);
|
||||||
|
await using var pipeline = pipelineFactory.Create(launchProfileName);
|
||||||
await pipeline.InitializeAsync(cancellationToken);
|
await pipeline.InitializeAsync(cancellationToken);
|
||||||
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
||||||
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
||||||
@@ -99,7 +107,9 @@ public sealed class AsrDiagnosticService
|
|||||||
string path,
|
string path,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var reader = new WaveFileReader(path);
|
var reader = new WaveFileReader(path);
|
||||||
|
try
|
||||||
|
{
|
||||||
var format = reader.WaveFormat;
|
var format = reader.WaveFormat;
|
||||||
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
|
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
|
||||||
{
|
{
|
||||||
@@ -113,6 +123,18 @@ public sealed class AsrDiagnosticService
|
|||||||
{
|
{
|
||||||
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
|
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
reader.Dispose();
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
// NAudio can throw while disposing reader streams from async iterators; diagnostics should not fail after reading all chunks.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
|
public sealed class AzureSpeechRecognitionPipelineBuilder :
|
||||||
|
SpeechRecognitionPipelineBuilder,
|
||||||
|
ISpeechRecognitionPipelineBuilder
|
||||||
|
{
|
||||||
|
public AzureSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||||
|
: base(services)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProviderName => "azure-speech";
|
||||||
|
|
||||||
|
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
return new AzureSpeechRecognitionPipeline(CreateProfiled<AzureSpeechStreamingTranscriptionProvider>(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,9 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var enumerator = audio.GetAsyncEnumerator(cancellationToken);
|
var enumerator = audio.GetAsyncEnumerator(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
if (!await enumerator.MoveNextAsync())
|
if (!await enumerator.MoveNextAsync())
|
||||||
{
|
{
|
||||||
yield break;
|
yield break;
|
||||||
@@ -94,6 +96,18 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
}
|
}
|
||||||
|
|
||||||
await pumpTask.WaitAsync(cancellationToken);
|
await pumpTask.WaitAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await enumerator.DisposeAsync();
|
||||||
|
}
|
||||||
|
catch (NotSupportedException)
|
||||||
|
{
|
||||||
|
// Some diagnostic async enumerable sources expose DisposeAsync but throw after the stream has been read.
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal ConversationTranscriber CreateTranscriber(SpeechConfig speechConfig, AudioConfig audioConfig)
|
internal ConversationTranscriber CreateTranscriber(SpeechConfig speechConfig, AudioConfig audioConfig)
|
||||||
@@ -150,7 +164,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
$"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}.");
|
$"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var speechConfig = SpeechConfig.FromEndpoint(GetEndpoint(azure), key);
|
var speechConfig = CreateSpeechConfig(azure, key);
|
||||||
var autoDetectLanguages = GetAutoDetectLanguages(azure);
|
var autoDetectLanguages = GetAutoDetectLanguages(azure);
|
||||||
if (autoDetectLanguages.Count == 0)
|
if (autoDetectLanguages.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -167,6 +181,13 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
speechConfig.SetProperty(
|
speechConfig.SetProperty(
|
||||||
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
|
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
|
||||||
azure.DiarizeIntermediateResults ? "true" : "false");
|
azure.DiarizeIntermediateResults ? "true" : "false");
|
||||||
|
if (!string.IsNullOrWhiteSpace(azure.PostProcessingOption))
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Azure Speech post-processing option {PostProcessingOption} is configured but skipped because the live Azure backend uses ConversationTranscriber; Speech SDK post-processing is documented for SpeechRecognizer.",
|
||||||
|
azure.PostProcessingOption.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
return speechConfig;
|
return speechConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +206,17 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal static Uri GetEndpoint(AzureSpeechOptions options)
|
internal static Uri GetEndpoint(AzureSpeechOptions options)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(options.Endpoint)
|
||||||
|
? throw new InvalidOperationException("Azure Speech endpoint must be configured.")
|
||||||
|
: new Uri(options.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static SpeechConfig CreateSpeechConfig(AzureSpeechOptions options, string key)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(options.Endpoint))
|
if (!string.IsNullOrWhiteSpace(options.Endpoint))
|
||||||
{
|
{
|
||||||
return new Uri(options.Endpoint);
|
return SpeechConfig.FromEndpoint(GetEndpoint(options), key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(options.Region))
|
if (string.IsNullOrWhiteSpace(options.Region))
|
||||||
@@ -196,7 +224,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
|||||||
throw new InvalidOperationException("Azure Speech region or endpoint must be configured.");
|
throw new InvalidOperationException("Azure Speech region or endpoint must be configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uri($"wss://{options.Region}.stt.speech.microsoft.com/speech/universal/v2");
|
return SpeechConfig.FromSubscription(key, options.Region.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string NormalizeLanguageIdMode(string? value)
|
internal static string NormalizeLanguageIdMode(string? value)
|
||||||
|
|||||||
@@ -1,29 +1,75 @@
|
|||||||
|
using MeetingAssistant.LaunchProfiles;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace MeetingAssistant.Transcription;
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
public sealed class ConfiguredSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
public sealed class ConfiguredSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||||
{
|
{
|
||||||
private readonly IServiceProvider services;
|
private readonly IReadOnlyDictionary<string, ISpeechRecognitionPipelineBuilder> builders;
|
||||||
private readonly MeetingAssistantOptions options;
|
private readonly MeetingAssistantOptions options;
|
||||||
|
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
||||||
|
|
||||||
public ConfiguredSpeechRecognitionPipelineFactory(
|
public ConfiguredSpeechRecognitionPipelineFactory(
|
||||||
IServiceProvider services,
|
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
|
||||||
IOptions<MeetingAssistantOptions> options)
|
IOptions<MeetingAssistantOptions> options)
|
||||||
|
: this(builders, options, launchProfiles: null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConfiguredSpeechRecognitionPipelineFactory(
|
||||||
|
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
|
||||||
|
IOptions<MeetingAssistantOptions> options,
|
||||||
|
ILaunchProfileOptionsProvider? launchProfiles)
|
||||||
{
|
{
|
||||||
this.services = services;
|
|
||||||
this.options = options.Value;
|
this.options = options.Value;
|
||||||
|
this.launchProfiles = launchProfiles;
|
||||||
|
this.builders = builders
|
||||||
|
.GroupBy(builder => builder.ProviderName, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
group => group.Key,
|
||||||
|
group => group.Count() == 1
|
||||||
|
? group.Single()
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"Speech recognition pipeline provider '{group.Key}' is registered more than once."),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (this.builders.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("No speech recognition pipeline builders are registered.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public ISpeechRecognitionPipeline Create()
|
public ISpeechRecognitionPipeline Create()
|
||||||
{
|
{
|
||||||
return options.Recording.TranscriptionProvider switch
|
return Create(null);
|
||||||
{
|
|
||||||
"funasr" => ActivatorUtilities.CreateInstance<FunAsrSpeechRecognitionPipeline>(services),
|
|
||||||
"whisper-local" => ActivatorUtilities.CreateInstance<WhisperLocalSpeechRecognitionPipeline>(services),
|
|
||||||
"azure-speech" => ActivatorUtilities.CreateInstance<AzureSpeechRecognitionPipeline>(services),
|
|
||||||
_ => throw new InvalidOperationException(
|
|
||||||
$"Unsupported speech recognition pipeline '{options.Recording.TranscriptionProvider}'.")
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||||
|
{
|
||||||
|
var profileOptions = ResolveOptions(launchProfileName);
|
||||||
|
if (!builders.TryGetValue(profileOptions.Recording.TranscriptionProvider, out var builder))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Unsupported speech recognition pipeline '{profileOptions.Recording.TranscriptionProvider}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.Create(profileOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
||||||
|
{
|
||||||
|
if (launchProfiles is not null)
|
||||||
|
{
|
||||||
|
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
||||||
|
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Launch profile '{launchProfileName}' was requested, but no launch profile provider is registered.");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
|
public sealed class FunAsrSpeechRecognitionPipelineBuilder :
|
||||||
|
SpeechRecognitionPipelineBuilder,
|
||||||
|
ISpeechRecognitionPipelineBuilder
|
||||||
|
{
|
||||||
|
public FunAsrSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||||
|
: base(services)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProviderName => "funasr";
|
||||||
|
|
||||||
|
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
var backendLifecycle = GetRequiredService<IFunAsrBackendLifecycle>();
|
||||||
|
return new FunAsrSpeechRecognitionPipeline(
|
||||||
|
CreateProfiled<FunAsrStreamingTranscriptionProvider>(options),
|
||||||
|
backendLifecycle,
|
||||||
|
CreateProfiled<FunAsrTranscriptFinalizer>(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,20 @@ public interface IDictationWordStore
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<string>> ReadWordsAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return ReadWordsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken);
|
Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<string>> AddWordAsync(
|
||||||
|
string word,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return AddWordAsync(word, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public interface ISpeechRecognitionPipeline : IAsyncDisposable
|
|||||||
public interface ISpeechRecognitionPipelineFactory
|
public interface ISpeechRecognitionPipelineFactory
|
||||||
{
|
{
|
||||||
ISpeechRecognitionPipeline Create();
|
ISpeechRecognitionPipeline Create();
|
||||||
|
|
||||||
|
ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||||
|
{
|
||||||
|
return Create();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record SpeechRecognitionPipelineOptions(
|
public sealed record SpeechRecognitionPipelineOptions(
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
|
public interface ISpeechRecognitionPipelineBuilder
|
||||||
|
{
|
||||||
|
string ProviderName { get; }
|
||||||
|
|
||||||
|
ISpeechRecognitionPipeline Create(MeetingAssistantOptions options);
|
||||||
|
}
|
||||||
@@ -13,7 +13,14 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = GetPath();
|
return await ReadWordsAsync(options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ReadWordsAsync(
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = GetPath(options);
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
@@ -25,7 +32,15 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = GetPath();
|
return await AddWordAsync(word, options, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> AddWordAsync(
|
||||||
|
string word,
|
||||||
|
MeetingAssistantOptions options,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var path = GetPath(options);
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
var existing = File.Exists(path)
|
var existing = File.Exists(path)
|
||||||
? await File.ReadAllLinesAsync(path, cancellationToken)
|
? await File.ReadAllLinesAsync(path, cancellationToken)
|
||||||
@@ -39,7 +54,7 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
|||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetPath()
|
private static string GetPath(MeetingAssistantOptions options)
|
||||||
{
|
{
|
||||||
return VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath);
|
return VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
|
public abstract class SpeechRecognitionPipelineBuilder
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider services;
|
||||||
|
|
||||||
|
protected SpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||||
|
{
|
||||||
|
this.services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T CreateProfiled<T>(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
return ActivatorUtilities.CreateInstance<T>(services, Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected T GetRequiredService<T>()
|
||||||
|
where T : notnull
|
||||||
|
{
|
||||||
|
return services.GetRequiredService<T>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace MeetingAssistant.Transcription;
|
||||||
|
|
||||||
|
public sealed class WhisperLocalSpeechRecognitionPipelineBuilder :
|
||||||
|
SpeechRecognitionPipelineBuilder,
|
||||||
|
ISpeechRecognitionPipelineBuilder
|
||||||
|
{
|
||||||
|
public WhisperLocalSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||||
|
: base(services)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProviderName => "whisper-local";
|
||||||
|
|
||||||
|
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||||
|
{
|
||||||
|
return new WhisperLocalSpeechRecognitionPipeline(
|
||||||
|
CreateProfiled<WhisperLocalStreamingTranscriptionProvider>(options),
|
||||||
|
CreateProfiled<PyannoteTranscriptFinalizer>(options));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,16 +80,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AzureSpeech": {
|
"AzureSpeech": {
|
||||||
"Endpoint": "wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2",
|
"Endpoint": "",
|
||||||
"Region": "germanywestcentral",
|
"Region": "westeurope",
|
||||||
"Language": "de-DE",
|
"Language": "de-DE",
|
||||||
"AutoDetectLanguages": [
|
"AutoDetectLanguages": [
|
||||||
"de-DE"
|
"de-DE"
|
||||||
],
|
],
|
||||||
"LanguageIdMode": "Continuous",
|
|
||||||
"KeyEnv": "AZURE_SPEECH_KEY",
|
"KeyEnv": "AZURE_SPEECH_KEY",
|
||||||
"RecognitionStopTimeout": "00:00:10",
|
"RecognitionStopTimeout": "00:00:10",
|
||||||
"DiarizeIntermediateResults": true,
|
"DiarizeIntermediateResults": true,
|
||||||
|
"PostProcessingOption": "",
|
||||||
"PhraseListWeight": 1.5
|
"PhraseListWeight": 1.5
|
||||||
},
|
},
|
||||||
"SpeakerIdentification": {
|
"SpeakerIdentification": {
|
||||||
@@ -103,7 +103,8 @@
|
|||||||
"MaxSnippetsPerSpeaker": 3,
|
"MaxSnippetsPerSpeaker": 3,
|
||||||
"SilenceBetweenSnippetsSeconds": 1,
|
"SilenceBetweenSnippetsSeconds": 1,
|
||||||
"LiveSampleBufferDuration": "00:10:00",
|
"LiveSampleBufferDuration": "00:10:00",
|
||||||
"MergeRecentIdentityAge": "14.00:00:00"
|
"MergeRecentIdentityAge": "14.00:00:00",
|
||||||
|
"MatchTimeout": "00:03:00"
|
||||||
},
|
},
|
||||||
"Agent": {
|
"Agent": {
|
||||||
"Endpoint": "https://litellm.schweigert.cloud",
|
"Endpoint": "https://litellm.schweigert.cloud",
|
||||||
@@ -122,6 +123,19 @@
|
|||||||
},
|
},
|
||||||
"Api": {
|
"Api": {
|
||||||
"PublicBaseUrl": "http://localhost:5090"
|
"PublicBaseUrl": "http://localhost:5090"
|
||||||
|
},
|
||||||
|
"LaunchProfiles": {
|
||||||
|
"english": {
|
||||||
|
"Hotkey": {
|
||||||
|
"Toggle": "Ctrl+Alt+E"
|
||||||
|
},
|
||||||
|
"AzureSpeech": {
|
||||||
|
"Language": "en-US",
|
||||||
|
"AutoDetectLanguages": [
|
||||||
|
"en-US"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ Meeting notes link to separate transcript, assistant context, and summary markdo
|
|||||||
|
|
||||||
Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary.
|
Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary.
|
||||||
|
|
||||||
Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as recording, final speaker recognition, and summary generation progress.
|
Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as metadata lookup, recording, final speaker recognition, and summary generation progress.
|
||||||
|
|
||||||
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
|
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
## 1. Build Targets
|
|
||||||
|
|
||||||
- [ ] 1.1 Add explicit macOS and Linux target framework/runtime build coverage.
|
|
||||||
- [ ] 1.2 Keep Windows-only implementations excluded from macOS and Linux builds.
|
|
||||||
- [ ] 1.3 Add platform service registration for unsupported or future macOS/Linux capture and hotkey implementations.
|
|
||||||
|
|
||||||
## 2. Tests
|
|
||||||
|
|
||||||
- [ ] 2.1 Split platform-specific tests by compilation target or runtime guard.
|
|
||||||
- [ ] 2.2 Add CI/build commands that verify Windows, macOS, and Linux compile targets.
|
|
||||||
- [ ] 2.3 Add at least one portable compile test that proves Windows-only packages are not required by non-Windows builds.
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Why
|
||||||
|
Meeting Assistant currently has one global configuration, one hotkey, and one implicit ASR/runtime setup. In practice, the user needs to start meetings with different speech-recognition settings, such as German-only versus English-only recognition, without editing configuration and restarting between meetings.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Add named launch profiles to configuration.
|
||||||
|
- Treat the root `MeetingAssistant` configuration as the `default` launch profile.
|
||||||
|
- Resolve non-default launch profiles by copying the default settings and binding the named profile override onto that copy.
|
||||||
|
- Add profile-aware endpoint routes while preserving the existing default-profile URLs.
|
||||||
|
- Register one global hotkey per launch profile, requiring distinct hotkey definitions.
|
||||||
|
- Add an `english` launch profile that keeps the current default settings except for English Azure Speech recognition and its own hotkey.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Existing URLs and the existing hotkey continue to operate the default profile.
|
||||||
|
- Named profiles are addressed through profile URLs, allowing diagnostics and recording commands to select the desired backend/language profile.
|
||||||
|
- Speech pipeline creation becomes profile-aware.
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Recording can be launched through named profiles
|
||||||
|
Meeting Assistant SHALL treat the root `MeetingAssistant` configuration as a launch profile named `default`.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow additional named launch profiles to be configured as overrides of the default profile. A named profile SHALL be resolved by binding the default profile first, then binding the named profile override onto the same option object.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL preserve existing recording endpoint URLs as default-profile URLs and SHALL provide equivalent named-profile URLs that include the profile name.
|
||||||
|
|
||||||
|
Each launch profile SHALL have a distinct global hotkey. Pressing a profile hotkey SHALL toggle recording using that profile's resolved configuration.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use the selected launch profile's vault and recording storage settings when it creates meeting notes, transcripts, assistant context notes, summary notes, temporary recordings, and dictation-word inputs for that meeting.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use the selected launch profile's summary-agent settings when it runs the summary pipeline for that meeting.
|
||||||
|
|
||||||
|
#### Scenario: Default profile keeps existing recording URL
|
||||||
|
- **WHEN** the user calls the existing recording start URL without a profile name
|
||||||
|
- **THEN** Meeting Assistant starts recording with the `default` launch profile
|
||||||
|
|
||||||
|
#### Scenario: Named profile starts recording
|
||||||
|
- **WHEN** the user calls the named-profile recording start URL for profile `english`
|
||||||
|
- **THEN** Meeting Assistant starts recording using the resolved `english` launch profile configuration
|
||||||
|
|
||||||
|
#### Scenario: Named profile stores meeting artifacts in profile folders
|
||||||
|
- **GIVEN** launch profile `english` overrides vault folders and summary-agent settings
|
||||||
|
- **WHEN** the user starts and stops a recording with launch profile `english`
|
||||||
|
- **THEN** Meeting Assistant creates the meeting note, transcript, assistant context note, and summary note using the resolved `english` vault folders
|
||||||
|
- **AND** Meeting Assistant runs the summary pipeline using the resolved `english` summary-agent settings
|
||||||
|
|
||||||
|
#### Scenario: Profile hotkeys must be distinct
|
||||||
|
- **GIVEN** two launch profiles configure the same toggle hotkey
|
||||||
|
- **WHEN** Meeting Assistant resolves launch profiles
|
||||||
|
- **THEN** Meeting Assistant rejects the configuration before registering global hotkeys
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Speech recognition diagnostics are launch-profile aware
|
||||||
|
Meeting Assistant SHALL preserve existing ASR diagnostic endpoint URLs as default-profile URLs.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL provide equivalent named-profile ASR diagnostic endpoint URLs that include the profile name.
|
||||||
|
|
||||||
|
When a named-profile ASR diagnostic endpoint is used, Meeting Assistant SHALL create the speech recognition pipeline from the resolved profile configuration.
|
||||||
|
|
||||||
|
#### Scenario: Default ASR diagnostic URL uses default profile
|
||||||
|
- **WHEN** the user submits a WAV file to the existing ASR diagnostic endpoint URL
|
||||||
|
- **THEN** Meeting Assistant streams the WAV through the default launch profile's configured speech recognition pipeline
|
||||||
|
|
||||||
|
#### Scenario: Named ASR diagnostic URL uses named profile
|
||||||
|
- **WHEN** the user submits a WAV file to the ASR diagnostic endpoint URL for profile `english`
|
||||||
|
- **THEN** Meeting Assistant streams the WAV through the resolved `english` launch profile's configured speech recognition pipeline
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
## 1. Specification
|
||||||
|
- [x] 1.1 Define launch profile requirements for recording control and endpoint routing.
|
||||||
|
- [x] 1.2 Define launch profile requirements for profile-aware ASR pipeline selection.
|
||||||
|
|
||||||
|
## 2. Implementation
|
||||||
|
- [x] 2.1 Add launch profile option resolution with default-plus-override merge behavior.
|
||||||
|
- [x] 2.2 Preserve existing default endpoint URLs and add named profile URL variants.
|
||||||
|
- [x] 2.3 Pass selected launch profiles into recording and ASR diagnostics pipeline creation.
|
||||||
|
- [x] 2.4 Register distinct hotkeys for all launch profiles.
|
||||||
|
- [x] 2.5 Add an `english` launch profile override to appsettings.
|
||||||
|
- [x] 2.6 Use the selected launch profile for meeting artifact storage paths, dictation words, and summary-agent settings.
|
||||||
|
|
||||||
|
## 3. Validation
|
||||||
|
- [x] 3.1 Add tests for default and named launch profile option resolution.
|
||||||
|
- [x] 3.2 Add tests for profile-aware diagnostic endpoint routing.
|
||||||
|
- [x] 3.3 Add tests for distinct profile hotkey planning.
|
||||||
|
- [x] 3.4 Add tests for profile-scoped meeting artifact paths and summary-agent settings.
|
||||||
|
- [x] 3.5 Run focused tests.
|
||||||
+7
@@ -83,6 +83,8 @@ When a match is confirmed and the identity has a canonical name, Meeting Assista
|
|||||||
|
|
||||||
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
||||||
|
|
||||||
|
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
|
||||||
|
|
||||||
#### Scenario: Finished transcript is relabeled after a confirmed match
|
#### Scenario: Finished transcript is relabeled after a confirmed match
|
||||||
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||||
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||||
@@ -100,6 +102,11 @@ When a match is confirmed and the matched speaker is not already listed in meeti
|
|||||||
- **WHEN** live or final speaker matching confirms a diarized speaker is `Christopher`
|
- **WHEN** live or final speaker matching confirms a diarized speaker is `Christopher`
|
||||||
- **THEN** Meeting Assistant does not add another attendee for `Christopher`
|
- **THEN** Meeting Assistant does not add another attendee for `Christopher`
|
||||||
|
|
||||||
|
#### Scenario: Calendar attendee aliases are deduplicated
|
||||||
|
- **GIVEN** the speaker identity database contains canonical speaker `Karl Berger` with alias `Berger, Karl`
|
||||||
|
- **WHEN** calendar metadata provides attendees `Karl Berger` and `Berger, Karl`
|
||||||
|
- **THEN** Meeting Assistant writes a single attendee `Karl Berger` to the meeting note
|
||||||
|
|
||||||
#### Scenario: Attendee identities are tried first
|
#### Scenario: Attendee identities are tried first
|
||||||
- **GIVEN** the meeting attendees include names matching known identity display names or aliases
|
- **GIVEN** the meeting attendees include names matching known identity display names or aliases
|
||||||
- **WHEN** Meeting Assistant tries to identify a diarized speaker
|
- **WHEN** Meeting Assistant tries to identify a diarized speaker
|
||||||
+3
-1
@@ -18,6 +18,7 @@
|
|||||||
- [x] 2.13 Run live speaker identification incrementally when new unmapped speakers appear or attendees change.
|
- [x] 2.13 Run live speaker identification incrementally when new unmapped speakers appear or attendees change.
|
||||||
- [x] 2.14 Maintain speaker identity last-modified timestamps for active-age filtering.
|
- [x] 2.14 Maintain speaker identity last-modified timestamps for active-age filtering.
|
||||||
- [x] 2.15 Add identified speaker display names to meeting note attendees without duplicating display names or aliases.
|
- [x] 2.15 Add identified speaker display names to meeting note attendees without duplicating display names or aliases.
|
||||||
|
- [x] 2.16 Canonicalize calendar attendee names through identity canonical names and aliases before writing meeting notes.
|
||||||
|
|
||||||
## 3. Validation
|
## 3. Validation
|
||||||
- [x] 3.1 Add tests for candidate creation, elimination, canonical promotion, and reset on empty candidate intersection.
|
- [x] 3.1 Add tests for candidate creation, elimination, canonical promotion, and reset on empty candidate intersection.
|
||||||
@@ -31,4 +32,5 @@
|
|||||||
- [x] 3.9 Add tests for incremental live speaker identification triggers.
|
- [x] 3.9 Add tests for incremental live speaker identification triggers.
|
||||||
- [x] 3.10 Add tests for speaker identity last-modified timestamp creation, updates, and schema upgrade.
|
- [x] 3.10 Add tests for speaker identity last-modified timestamp creation, updates, and schema upgrade.
|
||||||
- [x] 3.11 Add tests for adding matched speaker identities to meeting attendees and alias duplicate suppression.
|
- [x] 3.11 Add tests for adding matched speaker identities to meeting attendees and alias duplicate suppression.
|
||||||
- [x] 3.12 Run `dotnet test`.
|
- [x] 3.12 Add tests for calendar attendee canonicalization and alias duplicate suppression.
|
||||||
|
- [x] 3.13 Run `dotnet test`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## Why
|
||||||
|
Azure Speech post-stream refinement can improve final segment quality while preserving the existing low-latency streaming transcription flow.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Add an `AzureSpeech.PostProcessingOption` setting.
|
||||||
|
- Keep the default option unset while the live Azure backend uses `ConversationTranscriber`.
|
||||||
|
- Apply the configured option only for compatible Azure Speech SDK recognizer paths; skip it for `ConversationTranscriber` to preserve working live diarized transcription.
|
||||||
|
- Use a Speech resource region that supports post-stream refinement.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: `meeting-transcription`
|
||||||
|
- Affected code: Azure Speech options, Azure streaming transcription provider, appsettings, Azure provider tests
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Azure Speech post-stream refinement is configurable
|
||||||
|
Meeting Assistant SHALL allow the Azure Speech streaming transcription provider to configure the Speech SDK post-processing option.
|
||||||
|
|
||||||
|
When configured for a compatible Azure Speech SDK recognizer, Meeting Assistant SHALL set `SpeechServiceResponse_PostProcessingOption` on the Azure Speech SDK `SpeechConfig`.
|
||||||
|
|
||||||
|
The default application configuration SHALL keep `PostProcessingOption` empty while using live `ConversationTranscriber` and SHALL use a region that supports post-stream refinement for future compatible recognizer paths.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL keep using the existing Azure live streaming transcription flow rather than switching to batch or offline processing.
|
||||||
|
|
||||||
|
When the Azure live streaming backend uses `ConversationTranscriber`, Meeting Assistant SHALL prefer working live diarized transcription over applying a post-processing option that is only documented for `SpeechRecognizer`.
|
||||||
|
|
||||||
|
#### Scenario: Azure Speech post-refinement is configured
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||||
|
- **AND** the configured Azure Speech recognizer supports SDK post-processing
|
||||||
|
- **THEN** Meeting Assistant sets `SpeechServiceResponse_PostProcessingOption` to `PostRefinement` on the Azure Speech SDK configuration
|
||||||
|
|
||||||
|
#### Scenario: Azure live conversation transcription remains available
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||||
|
- **AND** the Azure backend is using live `ConversationTranscriber`
|
||||||
|
- **THEN** Meeting Assistant skips the post-processing SDK property
|
||||||
|
- **AND** continues using live conversation transcription
|
||||||
|
|
||||||
|
#### Scenario: Azure Speech post-processing is unset
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is empty
|
||||||
|
- **THEN** Meeting Assistant does not set a post-processing option on the Azure Speech SDK configuration
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
## 1. Specification
|
||||||
|
- [x] 1.1 Define Azure Speech post-stream refinement configuration behavior.
|
||||||
|
|
||||||
|
## 2. Implementation
|
||||||
|
- [x] 2.1 Add configurable Azure Speech post-processing option.
|
||||||
|
- [x] 2.2 Apply the configured option only to compatible Azure Speech SDK recognizer paths and skip it for live `ConversationTranscriber`.
|
||||||
|
- [x] 2.3 Configure Azure Speech for a post-stream-refinement-supported region.
|
||||||
|
- [x] 2.4 Provision or reuse an Azure Speech resource and set the local key environment variable.
|
||||||
|
|
||||||
|
## 3. Validation
|
||||||
|
- [x] 3.1 Add provider tests for the post-processing option.
|
||||||
|
- [x] 3.2 Run focused provider tests.
|
||||||
|
- [x] 3.3 Run full test suite.
|
||||||
|
- [x] 3.4 Run OpenSpec validation.
|
||||||
+4
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
Meeting Assistant currently focuses V1 runtime support on Windows capture and Outlook enrichment. We need a separate future change to define macOS and Linux build targets without expanding the V1 scope.
|
Meeting Assistant currently focuses V1 runtime support on Windows capture and Outlook enrichment. We need a separate future change to define macOS and Linux build targets without expanding the V1 scope.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Not planned for V1. This change is intentionally iced without implementation or spec updates.
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- Define supported macOS and Linux build targets for Meeting Assistant.
|
- Define supported macOS and Linux build targets for Meeting Assistant.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Status
|
||||||
|
|
||||||
|
Not planned for V1; archived without spec changes.
|
||||||
|
|
||||||
|
## 1. Build Targets
|
||||||
|
|
||||||
|
- [x] 1.1 Not planned for V1: add explicit macOS and Linux target framework/runtime build coverage.
|
||||||
|
- [x] 1.2 Not planned for V1: keep Windows-only implementations excluded from macOS and Linux builds.
|
||||||
|
- [x] 1.3 Not planned for V1: add platform service registration for unsupported or future macOS/Linux capture and hotkey implementations.
|
||||||
|
|
||||||
|
## 2. Tests
|
||||||
|
|
||||||
|
- [x] 2.1 Not planned for V1: split platform-specific tests by compilation target or runtime guard.
|
||||||
|
- [x] 2.2 Not planned for V1: add CI/build commands that verify Windows, macOS, and Linux compile targets.
|
||||||
|
- [x] 2.3 Not planned for V1: add at least one portable compile test that proves Windows-only packages are not required by non-Windows builds.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## Why
|
||||||
|
Speaker identity usage is currently tracked as a denormalized transcript count. That loses which meeting files caused the count and prevents Meeting Assistant from updating past transcript files when an identity is later named or merged.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Store speaker identity meeting participation as reference rows containing meeting note and transcript file addresses.
|
||||||
|
- Calculate identity usage counts from references when ordering matching candidates.
|
||||||
|
- Add transcript audit lines to referenced transcripts when an identity is named or two identities are merged.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Speaker identity SQLite schema gains a references table.
|
||||||
|
- Matching, attendee canonicalization, and merge ordering use calculated reference counts instead of a persisted counter.
|
||||||
|
- Existing databases keep working; legacy transcript-count columns may remain unused.
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
### Requirement: Meeting Assistant learns speaker identities locally
|
||||||
|
Meeting Assistant SHALL maintain a local SQLite speaker identity database in the user's application data folder.
|
||||||
|
|
||||||
|
The speaker identity database SHALL store speaker identities, optional canonical names, aliases, candidate names, meeting file references, and a bounded set of WAV snippets per identity.
|
||||||
|
|
||||||
|
Meeting file references SHALL include the meeting note file address and the transcript file address.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL calculate speaker identity participation counts from meeting file references when needed instead of persisting a denormalized transcript count.
|
||||||
|
|
||||||
|
Each speaker identity SHALL store a last-modified timestamp used by active-age filtering, and Meeting Assistant SHALL update it whenever the identity is created or modified by identification, candidate updates, snippet changes, reference changes, or merge operations.
|
||||||
|
|
||||||
|
The configured maximum snippet count per identity SHALL prevent unbounded growth.
|
||||||
|
|
||||||
|
#### Scenario: Unknown speaker is learned from meeting attendees
|
||||||
|
- **WHEN** a finished transcript contains an unmatched diarized speaker and the meeting note has attendees
|
||||||
|
- **THEN** Meeting Assistant stores a new unnamed speaker identity with candidate names from the attendees that were not already matched in that meeting
|
||||||
|
- **AND** stores a meeting file reference for that identity
|
||||||
|
|
||||||
|
#### Scenario: Speaker snippets are bounded
|
||||||
|
- **WHEN** Meeting Assistant adds a snippet for an identity that already has the configured maximum number of snippets
|
||||||
|
- **THEN** Meeting Assistant does not store more snippets for that identity
|
||||||
|
|
||||||
|
#### Scenario: Identity modification updates active-age timestamp
|
||||||
|
- **WHEN** Meeting Assistant creates, identifies, updates candidates for, stores snippets for, stores references for, or merges a speaker identity
|
||||||
|
- **THEN** Meeting Assistant updates that identity's last-modified timestamp
|
||||||
|
|
||||||
|
### Requirement: Speaker candidates are eliminated across meetings
|
||||||
|
Meeting Assistant SHALL update candidate names for a matched unnamed identity using the intersection of its existing candidate names and the current meeting attendees.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL treat identity aliases as acceptable names during candidate elimination and when excluding already-matched attendees from new unmatched speaker candidates.
|
||||||
|
|
||||||
|
When the candidate intersection leaves exactly one candidate, Meeting Assistant SHALL promote that candidate to the canonical speaker name.
|
||||||
|
|
||||||
|
When an identity receives a canonical name, Meeting Assistant SHALL append an audit line to each referenced transcript in the form `<date> <speaker label> was identified as <name>`.
|
||||||
|
|
||||||
|
When the candidate intersection is empty, Meeting Assistant SHALL replace the oldest stored snippet for that identity and reset candidates from the current meeting attendees, because the previous snippet may have been dirty.
|
||||||
|
|
||||||
|
#### Scenario: Candidate elimination promotes canonical name
|
||||||
|
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane`, `John`, and `Chris`
|
||||||
|
- **THEN** Meeting Assistant removes `Mike` from the identity candidates and promotes `John` as the canonical name
|
||||||
|
- **AND** appends the identity naming audit line to the identity's referenced transcripts
|
||||||
|
|
||||||
|
#### Scenario: Alias participates in candidate elimination
|
||||||
|
- **GIVEN** an unnamed identity has candidate name `Michael` and alias `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendee `Mike`
|
||||||
|
- **THEN** Meeting Assistant treats `Mike` as matching `Michael`
|
||||||
|
- **AND** promotes `Michael` as the canonical name
|
||||||
|
|
||||||
|
#### Scenario: Empty candidate intersection resets candidates
|
||||||
|
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane` and `Chris`
|
||||||
|
- **THEN** Meeting Assistant resets the identity candidates to `Jane` and `Chris`
|
||||||
|
- **AND** replaces the oldest stored snippet for that identity with the current speaker snippet
|
||||||
|
|
||||||
|
### Requirement: Speaker identity matches relabel transcripts
|
||||||
|
Meeting Assistant SHALL attempt to match unknown diarized speaker snippets against known speaker identities ordered by calculated meeting reference count.
|
||||||
|
|
||||||
|
Speaker identity matching SHALL use a dedicated Azure Speech diarization verifier component rather than the configured speech recognition pipeline.
|
||||||
|
|
||||||
|
The matcher SHALL test at most the configured batch size of known people per diarization session and continue with later batches until a match is found or no candidates remain.
|
||||||
|
|
||||||
|
The matcher SHALL prioritize identities whose canonical name or aliases match current meeting attendees.
|
||||||
|
|
||||||
|
After attendee-matched identities, the matcher SHALL order identities by calculated meeting reference count, filter out non-attendee identities whose last update is older than the configured active age, and cap the candidate set at the configured maximum match candidate count.
|
||||||
|
|
||||||
|
When a match is confirmed in final identity processing, Meeting Assistant SHALL store a meeting file reference for that identity.
|
||||||
|
|
||||||
|
When a match is confirmed and the identity has a canonical name, Meeting Assistant SHALL rewrite finished transcript segments for that diarized speaker with the canonical name.
|
||||||
|
|
||||||
|
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
||||||
|
|
||||||
|
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
|
||||||
|
|
||||||
|
#### Scenario: Finished transcript is relabeled after a confirmed match
|
||||||
|
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||||
|
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||||
|
- **THEN** Meeting Assistant rewrites `Guest03` segments in the transcript as `Chris`
|
||||||
|
|
||||||
|
#### Scenario: Confirmed match stores meeting reference
|
||||||
|
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||||
|
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||||
|
- **THEN** Meeting Assistant stores the meeting note and transcript file addresses as a reference for `Chris`
|
||||||
|
|
||||||
|
### Requirement: Speaker identities can be merged diagnostically
|
||||||
|
Meeting Assistant SHALL expose a diagnostic endpoint that merges duplicate speaker identities.
|
||||||
|
|
||||||
|
The merge process SHALL compare recently-created identities, using a configurable recent age that defaults to two weeks, against all other identities in matcher batches bounded by the configured batch size.
|
||||||
|
|
||||||
|
The merge process SHALL require a match and a second validation match using a different source sample before merging two identities.
|
||||||
|
|
||||||
|
When identities are merged, Meeting Assistant SHALL retain one identity, move useful names from the merged identity into aliases, combine meeting file references, retain a bounded set of snippets from both identities, and append an audit line to each referenced transcript in the form `<date> <name 1> and <name 2> were merged`.
|
||||||
|
|
||||||
|
#### Scenario: Recently-created duplicate identity is merged
|
||||||
|
- **GIVEN** a recently-created identity and an older identity have matching speaker snippets
|
||||||
|
- **WHEN** the diagnostic merge endpoint is triggered
|
||||||
|
- **THEN** Meeting Assistant validates the match twice with different source snippets
|
||||||
|
- **AND** merges the recent identity into the older identity
|
||||||
|
- **AND** stores the recent identity name as an alias on the retained identity
|
||||||
|
- **AND** keeps meeting file references from both identities
|
||||||
|
- **AND** appends the merge audit line to the referenced transcripts
|
||||||
|
|
||||||
|
#### Scenario: Old identities are not used as merge sources
|
||||||
|
- **GIVEN** two identities older than the configured recent age
|
||||||
|
- **WHEN** the diagnostic merge endpoint is triggered
|
||||||
|
- **THEN** Meeting Assistant does not compare them as source identities
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## 1. Implementation
|
||||||
|
- [x] Add speaker identity reference storage and schema creation.
|
||||||
|
- [x] Replace transcript-count ordering and match candidate data with calculated reference counts.
|
||||||
|
- [x] Record meeting note/transcript references when final identity processing matches or creates an identity.
|
||||||
|
- [x] Append transcript audit lines when identities are named or merged.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
- [x] Add/adjust behavior tests for references, calculated counts, naming audit lines, and merge audit lines.
|
||||||
|
- [x] Run focused speaker identity tests.
|
||||||
|
- [x] Run `openspec validate add-speaker-identity-references --strict`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## Why
|
||||||
|
The summary agent can edit supporting files such as project notes or the dictation dictionary. Those writes are currently invisible after the run unless the user notices file changes manually.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Track in-memory diffs for summary-agent writes outside the summary and assistant context files.
|
||||||
|
- Use a real diff implementation so whole-file rewrites produce useful changed-line output.
|
||||||
|
- Append the collected diff audit to the assistant context when the summary agent finishes.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Adds a .NET diff package dependency.
|
||||||
|
- Summary tool write paths gain audit capture for project files and dictation dictionary writes.
|
||||||
|
- Assistant context files receive an appended audit section for external files changed by the agent.
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
### Requirement: Meeting Assistant generates meeting outputs
|
||||||
|
Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use a Microsoft Agent Framework pipeline for the first summary implementation. The pipeline SHALL use a configurable OpenAI-compatible endpoint, model, and API key source, including support for a direct key or an environment variable name.
|
||||||
|
|
||||||
|
The summary pipeline SHALL retry transient model endpoint failures according to configurable reconnection attempts and delay settings.
|
||||||
|
|
||||||
|
The summary pipeline SHALL track context-window usage for the configured model using response usage when available and request-size estimates otherwise. The context-window limit, maximum output reserve, compaction enablement, compaction threshold, and Responses compact endpoint path SHALL be configurable.
|
||||||
|
|
||||||
|
When only the configured remaining context ratio is available, the summary pipeline SHALL try to compact the conversation through the configured OpenAI-compatible `POST /v1/responses/compact` endpoint. If that endpoint is unavailable or returns invalid data, the pipeline SHALL fall back to Microsoft Agent Framework compaction.
|
||||||
|
|
||||||
|
Fallback compaction SHALL become increasingly aggressive: collapse old tool results, summarize older conversation spans, keep only the last four user turns, and drop oldest groups if still over budget.
|
||||||
|
|
||||||
|
When summary generation fails after retries, Meeting Assistant SHALL write a markdown failure report to the configured summary note path. The failure report SHALL include a clickable retry link, error details, and the meeting artifact paths needed to diagnose or retry the run.
|
||||||
|
|
||||||
|
After transcript processing finishes for a recording, Meeting Assistant SHALL automatically invoke the summary pipeline for that meeting.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL expose an API operation to retry summary generation for a given summary note path. The retry operation SHALL resolve the linked meeting artifacts from the meeting note frontmatter and SHALL overwrite the existing summary note with either the new summary or a new failure report.
|
||||||
|
|
||||||
|
The summary pipeline SHALL expose tools scoped to the current meeting:
|
||||||
|
|
||||||
|
- `read_meetingnote`
|
||||||
|
- `read_transcript`
|
||||||
|
- `read_context`
|
||||||
|
- `read_usernotes`
|
||||||
|
- `read_glossary`
|
||||||
|
- `write_summary`
|
||||||
|
- `write_context`
|
||||||
|
- `list_projects`
|
||||||
|
- `list_projectfiles`
|
||||||
|
- `read_projectfile`
|
||||||
|
- `write_projectfile`
|
||||||
|
- `search`
|
||||||
|
|
||||||
|
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
||||||
|
|
||||||
|
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
|
||||||
|
|
||||||
|
The summary agent SHALL be able to read the meeting note, transcript, assistant context, glossary, and bound project files through tools.
|
||||||
|
|
||||||
|
The summary agent SHALL be able to write the summary and assistant context files as its owned artifacts.
|
||||||
|
|
||||||
|
For summary-agent writes to any file other than the summary file and assistant context file, Meeting Assistant SHALL record an in-memory diff containing removed and added lines.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use a diff implementation that can reduce whole-file rewrites to changed lines instead of treating every rewrite as a full replacement.
|
||||||
|
|
||||||
|
When the summary agent finishes, Meeting Assistant SHALL append the collected external write diffs to the assistant context file.
|
||||||
|
|
||||||
|
After writing the meeting summary, the summary agent SHALL update existing project files when the meeting produced project-relevant knowledge.
|
||||||
|
|
||||||
|
The assistant context note SHALL keep `title`, `start_time`, `end_time`, `meeting`, `transcript`, `summary`, `state`, and `agenda` in frontmatter. Meeting Assistant SHALL write `title` and `start_time` when the assistant context note is created, update `title` when later meeting metadata is discovered, and write `end_time` when transcript processing stops. When a Teams appointment is detected at recording start, the assistant context note SHALL also keep `scheduled_end` in frontmatter. The `state` value SHALL be one of `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`.
|
||||||
|
|
||||||
|
The summary note SHALL keep frontmatter links to meeting artifacts and SHALL copy `title`, `start_time`, and `end_time` from the meeting note when available. If the meeting note has no title, the summary agent SHALL be able to provide a title when writing the summary.
|
||||||
|
|
||||||
|
The summary agent SHALL be able to read and write the assistant context body as its own notebook using overwrite, replace, and insert line modes.
|
||||||
|
|
||||||
|
#### Scenario: Assistant context note is initialized
|
||||||
|
- **WHEN** Meeting Assistant starts a meeting session
|
||||||
|
- **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note
|
||||||
|
- **AND** the assistant context frontmatter state is `transcribing`
|
||||||
|
- **AND** the assistant context frontmatter includes `agenda`, empty when no agenda is known
|
||||||
|
- **AND** the assistant context frontmatter includes `scheduled_end` when Teams metadata supplied a scheduled end time
|
||||||
|
|
||||||
|
#### Scenario: Assistant context state follows meeting processing
|
||||||
|
- **WHEN** transcription, final speaker recognition, and summary generation progress
|
||||||
|
- **THEN** Meeting Assistant updates assistant context frontmatter state to `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error` as appropriate
|
||||||
|
- **AND** preserves existing `agenda` and `scheduled_end` frontmatter values
|
||||||
|
|
||||||
|
#### Scenario: External project write is audited
|
||||||
|
- **WHEN** the summary agent writes a bound project file
|
||||||
|
- **THEN** Meeting Assistant records the changed removed and added lines for that project file
|
||||||
|
- **AND** appends that diff to the assistant context after the agent finishes
|
||||||
|
|
||||||
|
#### Scenario: Dictionary write is audited
|
||||||
|
- **WHEN** the summary agent adds a dictation word and the dictionary file changes
|
||||||
|
- **THEN** Meeting Assistant records the changed removed and added lines for the dictionary file
|
||||||
|
- **AND** appends that diff to the assistant context after the agent finishes
|
||||||
|
|
||||||
|
#### Scenario: Owned artifact writes are not audited
|
||||||
|
- **WHEN** the summary agent writes the summary or assistant context file
|
||||||
|
- **THEN** Meeting Assistant does not include those writes in the external write diff audit
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
## 1. Implementation
|
||||||
|
- [x] Add an in-memory summary-agent write audit component.
|
||||||
|
- [x] Capture project-file and dictation-dictionary diffs while excluding summary and assistant-context writes.
|
||||||
|
- [x] Append the collected audit to assistant context after the summary agent finishes.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
- [x] Add behavior tests for project-file diff capture, dictionary diff capture, and summary/context exclusion.
|
||||||
|
- [x] Run focused summary/project tool tests.
|
||||||
|
- [x] Run `openspec validate add-summary-agent-write-diff-audit --strict`.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
## Why
|
||||||
|
Users can stop one meeting and immediately start the next while the previous meeting is still draining transcription, speaker recognition, or summarization. Per-run buffers and artifacts must remain isolated so old transcription output cannot be written to the new meeting.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Keep meeting note, transcript session, artifact paths, and run options bound to the run that created them.
|
||||||
|
- Allow a new recording to start after the previous capture has stopped even while the previous run continues final processing.
|
||||||
|
- Ensure rapidly-created artifact filenames are distinct.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Recording coordinator finalization paths use immutable per-run state instead of mutable current-run fields.
|
||||||
|
- Transcript, meeting note, assistant context, and summary filenames include higher-resolution timestamps.
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
### Requirement: Stopping recording drains captured audio through transcription
|
||||||
|
Meeting Assistant SHALL stop capturing new audio when recording mode is stopped, but it SHALL allow already captured audio to finish running through the configured speech recognition pipeline before the recording session completes.
|
||||||
|
|
||||||
|
When a recording is stopped, Meeting Assistant SHALL stop audio capture for that run and MAY continue draining transcription, speaker recognition, and summary generation for that stopped run.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow a new recording to start after the previous run's capture has stopped, even if the previous run is still draining transcription, speaker recognition, or summary generation.
|
||||||
|
|
||||||
|
Each run SHALL keep its own transcript session, meeting note path, artifact paths, options, audio buffer, live transcript buffer, and speaker mappings isolated from later runs.
|
||||||
|
|
||||||
|
Rapidly-created meeting note, transcript, assistant context, and summary artifact filenames SHALL be distinct.
|
||||||
|
|
||||||
|
#### Scenario: Hotkey stops recording with buffered audio
|
||||||
|
- **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription
|
||||||
|
- **THEN** Meeting Assistant stops capturing new audio and appends transcription output for the already captured audio before reporting the recording stopped
|
||||||
|
|
||||||
|
#### Scenario: New capture starts while previous run is finalizing
|
||||||
|
- **GIVEN** a recording has been stopped and is still finalizing its transcript or summary
|
||||||
|
- **WHEN** the user starts another recording
|
||||||
|
- **THEN** Meeting Assistant starts the new capture
|
||||||
|
- **AND** final transcription and summary output from the stopped run use the stopped run's files
|
||||||
|
- **AND** live transcription buffers from the stopped run are not written to the new run
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
## 1. Implementation
|
||||||
|
- [x] Add regression coverage for rapid stop/start while the first run is still finalizing.
|
||||||
|
- [x] Move finalization and summary reads/writes to per-run meeting note and artifact state.
|
||||||
|
- [x] Make rapidly-created artifact filenames distinct.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
- [x] Run focused recording coordinator tests.
|
||||||
|
- [x] Run `openspec validate allow-overlapping-meeting-finalization --strict`.
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
## Why
|
||||||
|
Meeting artifact notes currently emit frontmatter properties that can point back to the same note. Self-references add noise and make backlinks less useful.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- Meeting note frontmatter keeps links to transcript, assistant context, and summary.
|
||||||
|
- Transcript, assistant context, and summary notes keep links only to the other notes from the same run.
|
||||||
|
- The frontmatter property that would reference the note itself is omitted.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Shared artifact frontmatter rendering changes for transcript, assistant context, and summary notes.
|
||||||
|
- Existing resolver behavior based on meeting note links is unchanged.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
### Requirement: Meeting note template is coded and round-trippable
|
||||||
|
Meeting Assistant SHALL link the note files generated for one meeting run through frontmatter.
|
||||||
|
|
||||||
|
The meeting note frontmatter SHALL link to the transcript, assistant context, and summary notes.
|
||||||
|
|
||||||
|
Generated artifact notes SHALL link only to the other notes from the same run and SHALL omit the frontmatter property that would reference themselves.
|
||||||
|
|
||||||
|
#### Scenario: Meeting note links to generated artifacts
|
||||||
|
- **WHEN** Meeting Assistant creates a meeting note
|
||||||
|
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
|
||||||
|
|
||||||
|
#### Scenario: Generated artifacts do not self-reference
|
||||||
|
- **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter
|
||||||
|
- **THEN** the artifact frontmatter links to the other run notes
|
||||||
|
- **AND** the artifact frontmatter omits the property for the artifact's own note type
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
## 1. Implementation
|
||||||
|
- [x] Omit empty/self artifact link fields from rendered artifact frontmatter.
|
||||||
|
- [x] Ensure transcript, assistant context, and summary frontmatter include only other run artifacts.
|
||||||
|
|
||||||
|
## 2. Verification
|
||||||
|
- [x] Add/update artifact frontmatter behavior tests.
|
||||||
|
- [x] Run focused artifact/summary tests.
|
||||||
|
- [x] Run `openspec validate remove-artifact-self-frontmatter-links --strict`.
|
||||||
@@ -28,10 +28,25 @@ Meeting Assistant SHALL capture microphone input and computer output and combine
|
|||||||
### Requirement: Stopping recording drains captured audio through transcription
|
### Requirement: Stopping recording drains captured audio through transcription
|
||||||
Meeting Assistant SHALL stop capturing new audio when recording mode is stopped, but it SHALL allow already captured audio to finish running through the configured speech recognition pipeline before the recording session completes.
|
Meeting Assistant SHALL stop capturing new audio when recording mode is stopped, but it SHALL allow already captured audio to finish running through the configured speech recognition pipeline before the recording session completes.
|
||||||
|
|
||||||
|
When a recording is stopped, Meeting Assistant SHALL stop audio capture for that run and MAY continue draining transcription, speaker recognition, and summary generation for that stopped run.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow a new recording to start after the previous run's capture has stopped, even if the previous run is still draining transcription, speaker recognition, or summary generation.
|
||||||
|
|
||||||
|
Each run SHALL keep its own transcript session, meeting note path, artifact paths, options, audio buffer, live transcript buffer, and speaker mappings isolated from later runs.
|
||||||
|
|
||||||
|
Rapidly-created meeting note, transcript, assistant context, and summary artifact filenames SHALL be distinct.
|
||||||
|
|
||||||
#### Scenario: Hotkey stops recording with buffered audio
|
#### Scenario: Hotkey stops recording with buffered audio
|
||||||
- **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription
|
- **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription
|
||||||
- **THEN** Meeting Assistant stops capturing new audio and appends transcription output for the already captured audio before reporting the recording stopped
|
- **THEN** Meeting Assistant stops capturing new audio and appends transcription output for the already captured audio before reporting the recording stopped
|
||||||
|
|
||||||
|
#### Scenario: New capture starts while previous run is finalizing
|
||||||
|
- **GIVEN** a recording has been stopped and is still finalizing its transcript or summary
|
||||||
|
- **WHEN** the user starts another recording
|
||||||
|
- **THEN** Meeting Assistant starts the new capture
|
||||||
|
- **AND** final transcription and summary output from the stopped run use the stopped run's files
|
||||||
|
- **AND** live transcription buffers from the stopped run are not written to the new run
|
||||||
|
|
||||||
### Requirement: Transcripts are written to the configured vault folder
|
### Requirement: Transcripts are written to the configured vault folder
|
||||||
Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder.
|
Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder.
|
||||||
|
|
||||||
@@ -43,3 +58,35 @@ Meeting Assistant SHALL write live transcript text to a markdown file in the con
|
|||||||
- **WHEN** the speech recognition pipeline emits a live transcript segment
|
- **WHEN** the speech recognition pipeline emits a live transcript segment
|
||||||
- **THEN** Meeting Assistant appends that segment to the active transcript text file
|
- **THEN** Meeting Assistant appends that segment to the active transcript text file
|
||||||
|
|
||||||
|
### Requirement: Recording can be launched through named profiles
|
||||||
|
Meeting Assistant SHALL treat the root `MeetingAssistant` configuration as a launch profile named `default`.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL allow additional named launch profiles to be configured as overrides of the default profile. A named profile SHALL be resolved by binding the default profile first, then binding the named profile override onto the same option object.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL preserve existing recording endpoint URLs as default-profile URLs and SHALL provide equivalent named-profile URLs that include the profile name.
|
||||||
|
|
||||||
|
Each launch profile SHALL have a distinct global hotkey. Pressing a profile hotkey SHALL toggle recording using that profile's resolved configuration.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use the selected launch profile's vault and recording storage settings when it creates meeting notes, transcripts, assistant context notes, summary notes, temporary recordings, and dictation-word inputs for that meeting.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use the selected launch profile's summary-agent settings when it runs the summary pipeline for that meeting.
|
||||||
|
|
||||||
|
#### Scenario: Default profile keeps existing recording URL
|
||||||
|
- **WHEN** the user calls the existing recording start URL without a profile name
|
||||||
|
- **THEN** Meeting Assistant starts recording with the `default` launch profile
|
||||||
|
|
||||||
|
#### Scenario: Named profile starts recording
|
||||||
|
- **WHEN** the user calls the named-profile recording start URL for profile `english`
|
||||||
|
- **THEN** Meeting Assistant starts recording using the resolved `english` launch profile configuration
|
||||||
|
|
||||||
|
#### Scenario: Named profile stores meeting artifacts in profile folders
|
||||||
|
- **GIVEN** launch profile `english` overrides vault folders and summary-agent settings
|
||||||
|
- **WHEN** the user starts and stops a recording with launch profile `english`
|
||||||
|
- **THEN** Meeting Assistant creates the meeting note, transcript, assistant context note, and summary note using the resolved `english` vault folders
|
||||||
|
- **AND** Meeting Assistant runs the summary pipeline using the resolved `english` summary-agent settings
|
||||||
|
|
||||||
|
#### Scenario: Profile hotkeys must be distinct
|
||||||
|
- **GIVEN** two launch profiles configure the same toggle hotkey
|
||||||
|
- **WHEN** Meeting Assistant resolves launch profiles
|
||||||
|
- **THEN** Meeting Assistant rejects the configuration before registering global hotkeys
|
||||||
|
|
||||||
|
|||||||
@@ -34,45 +34,20 @@ The dictation words location SHALL point to a word-list artifact that speech rec
|
|||||||
- **THEN** Meeting Assistant can provide words from the configured dictation words path without changing the other vault locations
|
- **THEN** Meeting Assistant can provide words from the configured dictation words path without changing the other vault locations
|
||||||
|
|
||||||
### Requirement: Meeting note template is coded and round-trippable
|
### Requirement: Meeting note template is coded and round-trippable
|
||||||
Meeting Assistant SHALL own a coded meeting note template and a store that can save and read meeting notes.
|
Meeting Assistant SHALL link the note files generated for one meeting run through frontmatter.
|
||||||
|
|
||||||
The meeting note frontmatter SHALL include:
|
The meeting note frontmatter SHALL link to the transcript, assistant context, and summary notes.
|
||||||
|
|
||||||
- `start_time` as the meeting recording start timestamp
|
Generated artifact notes SHALL link only to the other notes from the same run and SHALL omit the frontmatter property that would reference themselves.
|
||||||
- `end_time` as the timestamp when transcript processing stops, empty while the meeting is active
|
|
||||||
- `attendees` as an array of display strings, where email is optional and may use a format such as `Mike <m.ike@ibm.com>`
|
|
||||||
- `projects` as an array because one meeting can cover multiple projects
|
|
||||||
- `transcript` as text containing a markdown link to the transcript note
|
|
||||||
- `assistant_context` as text containing a markdown link to the assistant context note
|
|
||||||
- `summary` as text containing a markdown link to the generated summary note once Meeting Assistant creates it
|
|
||||||
|
|
||||||
The user notes SHALL be the markdown body. Decisions and next steps SHALL NOT be required as frontmatter fields.
|
#### Scenario: Meeting note links to generated artifacts
|
||||||
|
- **WHEN** Meeting Assistant creates a meeting note
|
||||||
#### Scenario: Meeting note is saved
|
|
||||||
- **WHEN** Meeting Assistant saves a meeting note
|
|
||||||
- **THEN** it writes the coded frontmatter template and the user notes body to a markdown file in the configured meeting notes folder
|
|
||||||
|
|
||||||
#### Scenario: Meeting note links generated artifacts
|
|
||||||
- **WHEN** Meeting Assistant creates a meeting note for a new recording
|
|
||||||
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
|
- **THEN** the note frontmatter links to the configured transcript, assistant context, and summary note locations
|
||||||
|
|
||||||
#### Scenario: Generated artifacts link back to each other
|
#### Scenario: Generated artifacts do not self-reference
|
||||||
- **WHEN** Meeting Assistant creates transcript, assistant context, and summary artifacts
|
- **WHEN** Meeting Assistant writes transcript, assistant context, or summary artifact frontmatter
|
||||||
- **THEN** those artifacts use frontmatter links for the meeting note, transcript note, assistant context note, and summary note where applicable
|
- **THEN** the artifact frontmatter links to the other run notes
|
||||||
|
- **AND** the artifact frontmatter omits the property for the artifact's own note type
|
||||||
#### Scenario: Meeting note records session times
|
|
||||||
- **WHEN** Meeting Assistant creates a meeting note for a new recording
|
|
||||||
- **THEN** the note frontmatter contains `start_time`
|
|
||||||
- **AND** when transcript processing stops, Meeting Assistant writes `end_time` to the same note frontmatter
|
|
||||||
- **AND** transcript and summary frontmatter copy the meeting note title, `start_time`, and `end_time`
|
|
||||||
|
|
||||||
#### Scenario: Meeting note is read
|
|
||||||
- **WHEN** Meeting Assistant reads a meeting note created from the coded template
|
|
||||||
- **THEN** it returns the frontmatter fields and user notes body without losing attendees, projects, transcript link, assistant context link, or summary
|
|
||||||
|
|
||||||
#### Scenario: Meeting note frontmatter is updated
|
|
||||||
- **WHEN** Meeting Assistant reads a meeting note, changes frontmatter, and writes the note back
|
|
||||||
- **THEN** the existing user-authored markdown body remains unchanged
|
|
||||||
|
|
||||||
### Requirement: Meeting notes preserve user-authored content
|
### Requirement: Meeting notes preserve user-authored content
|
||||||
Meeting Assistant SHALL preserve user-authored meeting notes and include them as input when generating summaries, decisions, and next steps.
|
Meeting Assistant SHALL preserve user-authored meeting notes and include them as input when generating summaries, decisions, and next steps.
|
||||||
|
|||||||
@@ -39,9 +39,21 @@ The summary pipeline SHALL expose tools scoped to the current meeting:
|
|||||||
|
|
||||||
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
||||||
|
|
||||||
|
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
|
||||||
|
|
||||||
|
The summary agent SHALL be able to read the meeting note, transcript, assistant context, glossary, and bound project files through tools.
|
||||||
|
|
||||||
|
The summary agent SHALL be able to write the summary and assistant context files as its owned artifacts.
|
||||||
|
|
||||||
|
For summary-agent writes to any file other than the summary file and assistant context file, Meeting Assistant SHALL record an in-memory diff containing removed and added lines.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL use a diff implementation that can reduce whole-file rewrites to changed lines instead of treating every rewrite as a full replacement.
|
||||||
|
|
||||||
|
When the summary agent finishes, Meeting Assistant SHALL append the collected external write diffs to the assistant context file.
|
||||||
|
|
||||||
After writing the meeting summary, the summary agent SHALL update existing project files when the meeting produced project-relevant knowledge.
|
After writing the meeting summary, the summary agent SHALL update existing project files when the meeting produced project-relevant knowledge.
|
||||||
|
|
||||||
The assistant context note SHALL keep `meeting`, `transcript`, `summary`, `state`, and `agenda` in frontmatter. When a Teams appointment is detected at recording start, the assistant context note SHALL also keep `scheduled_end` in frontmatter. The `state` value SHALL be one of `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`.
|
The assistant context note SHALL keep `title`, `start_time`, `end_time`, `meeting`, `transcript`, `summary`, `state`, and `agenda` in frontmatter. Meeting Assistant SHALL write `title` and `start_time` when the assistant context note is created, update `title` when later meeting metadata is discovered, and write `end_time` when transcript processing stops. When a Teams appointment is detected at recording start, the assistant context note SHALL also keep `scheduled_end` in frontmatter. The `state` value SHALL be one of `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`.
|
||||||
|
|
||||||
The summary note SHALL keep frontmatter links to meeting artifacts and SHALL copy `title`, `start_time`, and `end_time` from the meeting note when available. If the meeting note has no title, the summary agent SHALL be able to provide a title when writing the summary.
|
The summary note SHALL keep frontmatter links to meeting artifacts and SHALL copy `title`, `start_time`, and `end_time` from the meeting note when available. If the meeting note has no title, the summary agent SHALL be able to provide a title when writing the summary.
|
||||||
|
|
||||||
@@ -50,12 +62,57 @@ The summary agent SHALL be able to read and write the assistant context body as
|
|||||||
#### Scenario: Assistant context note is initialized
|
#### Scenario: Assistant context note is initialized
|
||||||
- **WHEN** Meeting Assistant starts a meeting session
|
- **WHEN** Meeting Assistant starts a meeting session
|
||||||
- **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note
|
- **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note
|
||||||
- **AND** the assistant context frontmatter state is `transcribing`
|
- **AND** the assistant context frontmatter state is `collecting metadata`
|
||||||
- **AND** the assistant context frontmatter includes `agenda`, empty when no agenda is known
|
- **AND** the assistant context frontmatter includes `agenda`, empty when no agenda is known
|
||||||
- **AND** the assistant context frontmatter includes `scheduled_end` when Teams metadata supplied a scheduled end time
|
- **AND** the assistant context frontmatter includes `scheduled_end` when Teams metadata supplied a scheduled end time
|
||||||
|
|
||||||
#### Scenario: Assistant context state follows meeting processing
|
#### Scenario: Assistant context state follows meeting processing
|
||||||
- **WHEN** transcription, final speaker recognition, and summary generation progress
|
- **WHEN** transcription, final speaker recognition, and summary generation progress
|
||||||
- **THEN** Meeting Assistant updates assistant context frontmatter state to `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error` as appropriate
|
- **THEN** Meeting Assistant updates assistant context frontmatter state to `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error` as appropriate
|
||||||
- **AND** preserves existing `agenda` and `scheduled_end` frontmatter values
|
- **AND** preserves existing `agenda` and `scheduled_end` frontmatter values
|
||||||
|
|
||||||
|
#### Scenario: External project write is audited
|
||||||
|
- **WHEN** the summary agent writes a bound project file
|
||||||
|
- **THEN** Meeting Assistant records the changed removed and added lines for that project file
|
||||||
|
- **AND** appends that diff to the assistant context after the agent finishes
|
||||||
|
|
||||||
|
#### Scenario: Dictionary write is audited
|
||||||
|
- **WHEN** the summary agent adds a dictation word and the dictionary file changes
|
||||||
|
- **THEN** Meeting Assistant records the changed removed and added lines for the dictionary file
|
||||||
|
- **AND** appends that diff to the assistant context after the agent finishes
|
||||||
|
|
||||||
|
#### Scenario: Owned artifact writes are not audited
|
||||||
|
- **WHEN** the summary agent writes the summary or assistant context file
|
||||||
|
- **THEN** Meeting Assistant does not include those writes in the external write diff audit
|
||||||
|
|
||||||
|
### Requirement: Summary agent instructions are configurable
|
||||||
|
Meeting Assistant SHALL allow the summary agent initial prompt to be configured through application settings.
|
||||||
|
|
||||||
|
When no configured initial prompt is supplied, Meeting Assistant SHALL use the built-in default prompt.
|
||||||
|
|
||||||
|
#### Scenario: Configured initial prompt is used
|
||||||
|
- **WHEN** `MeetingAssistant:Agent:InitialPrompt` is configured with non-empty content
|
||||||
|
- **THEN** the summary agent uses that configured prompt as its base instructions
|
||||||
|
|
||||||
|
#### Scenario: Empty initial prompt falls back to built-in default
|
||||||
|
- **WHEN** `MeetingAssistant:Agent:InitialPrompt` is missing or blank
|
||||||
|
- **THEN** the summary agent uses the built-in default instructions
|
||||||
|
|
||||||
|
### Requirement: Summary agent receives bound project instructions
|
||||||
|
Meeting Assistant SHALL append project instructions to the summary agent instructions for projects bound to the meeting note frontmatter.
|
||||||
|
|
||||||
|
For each bound project folder under the configured projects folder, when an `AGENTS.md` file exists directly in that project folder, Meeting Assistant SHALL append the project name and the `AGENTS.md` content under a project instructions section.
|
||||||
|
|
||||||
|
When no bound projects have `AGENTS.md`, Meeting Assistant SHALL not append the project instructions section.
|
||||||
|
|
||||||
|
#### Scenario: Project AGENTS files are appended
|
||||||
|
- **GIVEN** the meeting note frontmatter lists projects `Alpha` and `Beta`
|
||||||
|
- **AND** both configured project folders contain `AGENTS.md`
|
||||||
|
- **WHEN** the summary agent is created
|
||||||
|
- **THEN** its instructions include `---`, `projects:`, `# Alpha`, Alpha's `AGENTS.md`, `# Beta`, and Beta's `AGENTS.md`
|
||||||
|
|
||||||
|
#### Scenario: Projects without AGENTS files are skipped
|
||||||
|
- **GIVEN** the meeting note frontmatter lists project `Alpha`
|
||||||
|
- **AND** the configured project folder does not contain `AGENTS.md`
|
||||||
|
- **WHEN** the summary agent is created
|
||||||
|
- **THEN** no empty project instruction entry is appended for `Alpha`
|
||||||
|
|||||||
@@ -188,3 +188,197 @@ When configured, Meeting Assistant SHALL manage a local FunASR backend lifecycle
|
|||||||
- **WHEN** Meeting Assistant stops after starting a managed FunASR backend
|
- **WHEN** Meeting Assistant stops after starting a managed FunASR backend
|
||||||
- **THEN** Meeting Assistant stops the managed backend process or container
|
- **THEN** Meeting Assistant stops the managed backend process or container
|
||||||
|
|
||||||
|
### Requirement: Meeting Assistant learns speaker identities locally
|
||||||
|
Meeting Assistant SHALL maintain a local SQLite speaker identity database in the user's application data folder.
|
||||||
|
|
||||||
|
The speaker identity database SHALL store speaker identities, optional canonical names, aliases, candidate names, meeting file references, and a bounded set of WAV snippets per identity.
|
||||||
|
|
||||||
|
Meeting file references SHALL include the meeting note file address and the transcript file address.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL calculate speaker identity participation counts from meeting file references when needed instead of persisting a denormalized transcript count.
|
||||||
|
|
||||||
|
Each speaker identity SHALL store a last-modified timestamp used by active-age filtering, and Meeting Assistant SHALL update it whenever the identity is created or modified by identification, candidate updates, snippet changes, reference changes, or merge operations.
|
||||||
|
|
||||||
|
The configured maximum snippet count per identity SHALL prevent unbounded growth.
|
||||||
|
|
||||||
|
#### Scenario: Unknown speaker is learned from meeting attendees
|
||||||
|
- **WHEN** a finished transcript contains an unmatched diarized speaker and the meeting note has attendees
|
||||||
|
- **THEN** Meeting Assistant stores a new unnamed speaker identity with candidate names from the attendees that were not already matched in that meeting
|
||||||
|
- **AND** stores a meeting file reference for that identity
|
||||||
|
|
||||||
|
#### Scenario: Speaker snippets are bounded
|
||||||
|
- **WHEN** Meeting Assistant adds a snippet for an identity that already has the configured maximum number of snippets
|
||||||
|
- **THEN** Meeting Assistant does not store more snippets for that identity
|
||||||
|
|
||||||
|
#### Scenario: Identity modification updates active-age timestamp
|
||||||
|
- **WHEN** Meeting Assistant creates, identifies, updates candidates for, stores snippets for, stores references for, or merges a speaker identity
|
||||||
|
- **THEN** Meeting Assistant updates that identity's last-modified timestamp
|
||||||
|
|
||||||
|
### Requirement: Speaker identities can be merged diagnostically
|
||||||
|
Meeting Assistant SHALL expose a diagnostic endpoint that merges duplicate speaker identities.
|
||||||
|
|
||||||
|
The merge process SHALL compare recently-created identities, using a configurable recent age that defaults to two weeks, against all other identities in matcher batches bounded by the configured batch size.
|
||||||
|
|
||||||
|
The merge process SHALL require a match and a second validation match using a different source sample before merging two identities.
|
||||||
|
|
||||||
|
When identities are merged, Meeting Assistant SHALL retain one identity, move useful names from the merged identity into aliases, combine meeting file references, retain a bounded set of snippets from both identities, and append an audit line to each referenced transcript in the form `<date> <name 1> and <name 2> were merged`.
|
||||||
|
|
||||||
|
#### Scenario: Recently-created duplicate identity is merged
|
||||||
|
- **GIVEN** a recently-created identity and an older identity have matching speaker snippets
|
||||||
|
- **WHEN** the diagnostic merge endpoint is triggered
|
||||||
|
- **THEN** Meeting Assistant validates the match twice with different source snippets
|
||||||
|
- **AND** merges the recent identity into the older identity
|
||||||
|
- **AND** stores the recent identity name as an alias on the retained identity
|
||||||
|
- **AND** keeps meeting file references from both identities
|
||||||
|
- **AND** appends the merge audit line to the referenced transcripts
|
||||||
|
|
||||||
|
#### Scenario: Old identities are not used as merge sources
|
||||||
|
- **GIVEN** two identities older than the configured recent age
|
||||||
|
- **WHEN** the diagnostic merge endpoint is triggered
|
||||||
|
- **THEN** Meeting Assistant does not compare them as source identities
|
||||||
|
|
||||||
|
### Requirement: Speaker candidates are eliminated across meetings
|
||||||
|
Meeting Assistant SHALL update candidate names for a matched unnamed identity using the intersection of its existing candidate names and the current meeting attendees.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL treat identity aliases as acceptable names during candidate elimination and when excluding already-matched attendees from new unmatched speaker candidates.
|
||||||
|
|
||||||
|
When the candidate intersection leaves exactly one candidate, Meeting Assistant SHALL promote that candidate to the canonical speaker name.
|
||||||
|
|
||||||
|
When an identity receives a canonical name, Meeting Assistant SHALL append an audit line to each referenced transcript in the form `<date> <speaker label> was identified as <name>`.
|
||||||
|
|
||||||
|
When the candidate intersection is empty, Meeting Assistant SHALL replace the oldest stored snippet for that identity and reset candidates from the current meeting attendees, because the previous snippet may have been dirty.
|
||||||
|
|
||||||
|
#### Scenario: Candidate elimination promotes canonical name
|
||||||
|
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane`, `John`, and `Chris`
|
||||||
|
- **THEN** Meeting Assistant removes `Mike` from the identity candidates and promotes `John` as the canonical name
|
||||||
|
- **AND** appends the identity naming audit line to the identity's referenced transcripts
|
||||||
|
|
||||||
|
#### Scenario: Alias participates in candidate elimination
|
||||||
|
- **GIVEN** an unnamed identity has candidate name `Michael` and alias `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendee `Mike`
|
||||||
|
- **THEN** Meeting Assistant treats `Mike` as matching `Michael`
|
||||||
|
- **AND** promotes `Michael` as the canonical name
|
||||||
|
|
||||||
|
#### Scenario: Empty candidate intersection resets candidates
|
||||||
|
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||||
|
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane` and `Chris`
|
||||||
|
- **THEN** Meeting Assistant resets the identity candidates to `Jane` and `Chris`
|
||||||
|
- **AND** replaces the oldest stored snippet for that identity with the current speaker snippet
|
||||||
|
|
||||||
|
### Requirement: Speaker identity matches relabel transcripts
|
||||||
|
Meeting Assistant SHALL attempt to match unknown diarized speaker snippets against known speaker identities ordered by calculated meeting reference count.
|
||||||
|
|
||||||
|
Speaker identity matching SHALL use a dedicated Azure Speech diarization verifier component rather than the configured speech recognition pipeline.
|
||||||
|
|
||||||
|
The matcher SHALL test at most the configured batch size of known people per diarization session and continue with later batches until a match is found or no candidates remain.
|
||||||
|
|
||||||
|
The matcher SHALL prioritize identities whose canonical name or aliases match current meeting attendees.
|
||||||
|
|
||||||
|
After attendee-matched identities, the matcher SHALL order identities by calculated meeting reference count, filter out non-attendee identities whose last update is older than the configured active age, and cap the candidate set at the configured maximum match candidate count.
|
||||||
|
|
||||||
|
When a match is confirmed in final identity processing, Meeting Assistant SHALL store a meeting file reference for that identity.
|
||||||
|
|
||||||
|
When a match is confirmed and the identity has a canonical name, Meeting Assistant SHALL rewrite finished transcript segments for that diarized speaker with the canonical name.
|
||||||
|
|
||||||
|
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
||||||
|
|
||||||
|
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
|
||||||
|
|
||||||
|
#### Scenario: Finished transcript is relabeled after a confirmed match
|
||||||
|
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||||
|
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||||
|
- **THEN** Meeting Assistant rewrites `Guest03` segments in the transcript as `Chris`
|
||||||
|
|
||||||
|
#### Scenario: Confirmed match stores meeting reference
|
||||||
|
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||||
|
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||||
|
- **THEN** Meeting Assistant stores the meeting note and transcript file addresses as a reference for `Chris`
|
||||||
|
|
||||||
|
### Requirement: Speaker matching runs during active transcription
|
||||||
|
Meeting Assistant SHALL start speaker identity matching only after the configured initial transcription duration has elapsed.
|
||||||
|
|
||||||
|
For backends that emit live diarized transcript segments, Meeting Assistant SHALL keep a bounded in-memory sliding audio buffer with chunk timestamps and extract candidate WAV snippets from that buffer when live diarized segments arrive.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL keep only the configured best candidate snippets per diarized speaker in memory. Better snippets SHALL be preferred when the segment looks like a continuous medium-length sentence.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL periodically match unresolved diarized speaker samples while transcription is active and attempt to match them against the local identity database.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL run live matching incrementally at the configured interval only when at least one new unmapped diarized speaker sample appears or the meeting note attendee frontmatter changes while unmapped speaker samples still exist.
|
||||||
|
|
||||||
|
When a speaker is matched during transcription, Meeting Assistant SHALL rewrite already-written live transcript segments for that diarized speaker and write future transcript segments using the canonical name.
|
||||||
|
|
||||||
|
Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished, using the latest meeting note frontmatter.
|
||||||
|
|
||||||
|
For backends that only provide diarization after finalization, Meeting Assistant SHALL defer speaker identity matching until finished diarization is available, extract candidate snippets from the completed temporary recording, complete identity matching, and only then allow summary generation to start.
|
||||||
|
|
||||||
|
#### Scenario: Matching waits for useful speech duration
|
||||||
|
- **WHEN** transcription has been active for less than the configured speaker identification initial delay
|
||||||
|
- **THEN** Meeting Assistant does not run speaker identity matching yet
|
||||||
|
|
||||||
|
#### Scenario: Live matching uses in-memory speaker samples
|
||||||
|
- **WHEN** a live diarized transcript segment identifies an unresolved speaker
|
||||||
|
- **THEN** Meeting Assistant extracts a WAV snippet for that segment from the in-memory sliding audio buffer
|
||||||
|
- **AND** uses retained speaker samples for live identity matching without reading the temporary recording file
|
||||||
|
|
||||||
|
#### Scenario: Live match rewrites current and future transcript writes
|
||||||
|
- **WHEN** periodic matching confirms that diarized speaker `Guest03` is canonical speaker `Chris`
|
||||||
|
- **THEN** already-written live transcript segments for `Guest03` are rewritten as `Chris`
|
||||||
|
- **AND** later live transcript segments for `Guest03` are written as `Chris`
|
||||||
|
|
||||||
|
#### Scenario: New live speaker triggers another identification round
|
||||||
|
- **GIVEN** live matching already checked the current unresolved speaker samples
|
||||||
|
- **WHEN** a new unmapped diarized speaker sample appears
|
||||||
|
- **THEN** Meeting Assistant runs another live matching round at the next configured interval
|
||||||
|
|
||||||
|
#### Scenario: Attendee changes trigger another identification round
|
||||||
|
- **GIVEN** live matching already checked unresolved speaker samples
|
||||||
|
- **WHEN** the meeting note attendee frontmatter changes
|
||||||
|
- **THEN** Meeting Assistant runs another live matching round at the next configured interval using the latest attendees
|
||||||
|
|
||||||
|
#### Scenario: Live matching does not make final identity decisions
|
||||||
|
- **WHEN** live matching finds a possible speaker identity during transcription
|
||||||
|
- **THEN** Meeting Assistant does not change candidate names, promote canonical names, increment counters, store snippets, or create unmatched identities
|
||||||
|
- **AND** the final speaker identity pass uses the latest meeting note attendees after transcription finishes
|
||||||
|
|
||||||
|
### Requirement: Speech recognition diagnostics are launch-profile aware
|
||||||
|
Meeting Assistant SHALL preserve existing ASR diagnostic endpoint URLs as default-profile URLs.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL provide equivalent named-profile ASR diagnostic endpoint URLs that include the profile name.
|
||||||
|
|
||||||
|
When a named-profile ASR diagnostic endpoint is used, Meeting Assistant SHALL create the speech recognition pipeline from the resolved profile configuration.
|
||||||
|
|
||||||
|
#### Scenario: Default ASR diagnostic URL uses default profile
|
||||||
|
- **WHEN** the user submits a WAV file to the existing ASR diagnostic endpoint URL
|
||||||
|
- **THEN** Meeting Assistant streams the WAV through the default launch profile's configured speech recognition pipeline
|
||||||
|
|
||||||
|
#### Scenario: Named ASR diagnostic URL uses named profile
|
||||||
|
- **WHEN** the user submits a WAV file to the ASR diagnostic endpoint URL for profile `english`
|
||||||
|
- **THEN** Meeting Assistant streams the WAV through the resolved `english` launch profile's configured speech recognition pipeline
|
||||||
|
|
||||||
|
### Requirement: Azure Speech post-stream refinement is configurable
|
||||||
|
Meeting Assistant SHALL allow the Azure Speech streaming transcription provider to configure the Speech SDK post-processing option.
|
||||||
|
|
||||||
|
When configured for a compatible Azure Speech SDK recognizer, Meeting Assistant SHALL set `SpeechServiceResponse_PostProcessingOption` on the Azure Speech SDK `SpeechConfig`.
|
||||||
|
|
||||||
|
The default application configuration SHALL keep `PostProcessingOption` empty while using live `ConversationTranscriber` and SHALL use a region that supports post-stream refinement for future compatible recognizer paths.
|
||||||
|
|
||||||
|
Meeting Assistant SHALL keep using the existing Azure live streaming transcription flow rather than switching to batch or offline processing.
|
||||||
|
|
||||||
|
When the Azure live streaming backend uses `ConversationTranscriber`, Meeting Assistant SHALL prefer working live diarized transcription over applying a post-processing option that is only documented for `SpeechRecognizer`.
|
||||||
|
|
||||||
|
#### Scenario: Azure Speech post-refinement is configured
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||||
|
- **AND** the configured Azure Speech recognizer supports SDK post-processing
|
||||||
|
- **THEN** Meeting Assistant sets `SpeechServiceResponse_PostProcessingOption` to `PostRefinement` on the Azure Speech SDK configuration
|
||||||
|
|
||||||
|
#### Scenario: Azure live conversation transcription remains available
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||||
|
- **AND** the Azure backend is using live `ConversationTranscriber`
|
||||||
|
- **THEN** Meeting Assistant skips the post-processing SDK property
|
||||||
|
- **AND** continues using live conversation transcription
|
||||||
|
|
||||||
|
#### Scenario: Azure Speech post-processing is unset
|
||||||
|
- **WHEN** `AzureSpeech.PostProcessingOption` is empty
|
||||||
|
- **THEN** Meeting Assistant does not set a post-processing option on the Azure Speech SDK configuration
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user