Public Access
Make meeting lifecycle stateful
This commit is contained in:
+229
-26
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Hotkeys;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
@@ -10,6 +11,7 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
|
||||
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
@@ -40,6 +42,7 @@ builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpea
|
||||
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentityAttendeeCanonicalizer, SpeakerIdentityAttendeeCanonicalizer>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArtifactResolver>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||
@@ -54,6 +57,9 @@ builder.Services.AddTransient<WhisperLocalStreamingTranscriptionProvider>();
|
||||
builder.Services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||
builder.Services.AddTransient<FunAsrTranscriptFinalizer>();
|
||||
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<MeetingRecordingCoordinator>();
|
||||
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("/profiles/{launchProfile}/recording/status", (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||
IMeetingMetadataProvider metadataProvider,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
DateTimeOffset? startedAt,
|
||||
double? timeoutSeconds,
|
||||
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 timeout = timeoutSeconds is > 0
|
||||
? TimeSpan.FromSeconds(timeoutSeconds.Value)
|
||||
: options.Value.Recording.MetadataLookupTimeout;
|
||||
: profileOptions.Recording.MetadataLookupTimeout;
|
||||
if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
|
||||
{
|
||||
return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync(
|
||||
@@ -102,78 +145,224 @@ app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
metadata,
|
||||
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 (
|
||||
SpeakerIdentityMergeDiagnosticRequest request,
|
||||
ISpeakerIdentityMergeService mergeService,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
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
|
||||
? TimeSpan.FromDays(request.RecentDays.Value)
|
||||
: options.Value.SpeakerIdentification.MergeRecentIdentityAge;
|
||||
: profileOptions.SpeakerIdentification.MergeRecentIdentityAge;
|
||||
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
|
||||
});
|
||||
}
|
||||
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken 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) =>
|
||||
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) =>
|
||||
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 (
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
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)
|
||||
{
|
||||
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 (
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SummaryPath))
|
||||
{
|
||||
return Results.BadRequest(new { error = "A summary path is required." });
|
||||
}
|
||||
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(request.SummaryPath, cancellationToken);
|
||||
if (artifacts is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No meeting note links to summary '{request.SummaryPath}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, cancellationToken));
|
||||
});
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
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 (
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
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))
|
||||
{
|
||||
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)
|
||||
{
|
||||
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 (
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
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))
|
||||
{
|
||||
@@ -182,7 +371,7 @@ app.MapPost("/asr/transcribe-file", async (
|
||||
|
||||
try
|
||||
{
|
||||
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, cancellationToken));
|
||||
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, launchProfile, cancellationToken));
|
||||
}
|
||||
catch (FileNotFoundException exception)
|
||||
{
|
||||
@@ -199,11 +388,24 @@ app.MapPost("/asr/transcribe-file", async (
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "ASR backend failed while processing the WAV file.");
|
||||
}
|
||||
});
|
||||
}
|
||||
app.MapPost("/asr/diarize-file", async (
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
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))
|
||||
{
|
||||
@@ -221,6 +423,7 @@ app.MapPost("/asr/diarize-file", async (
|
||||
return Results.Ok(await diagnostics.DiarizeWavAsync(
|
||||
fullPath,
|
||||
new SpeechRecognitionPipelineOptions(request.NumSpeakers),
|
||||
launchProfile,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -230,7 +433,7 @@ app.MapPost("/asr/diarize-file", async (
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "ASR diarization backend failed while processing the WAV file.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user