using MeetingAssistant; using MeetingAssistant.Hotkeys; using MeetingAssistant.LaunchProfiles; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Recording; using MeetingAssistant.Screenshots; using MeetingAssistant.Speakers; using MeetingAssistant.Summary; using MeetingAssistant.Taskbar; using MeetingAssistant.Transcription; using MeetingAssistant.Workflow; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("MeetingAssistant")); builder.Services.AddSingleton(); #if WINDOWS builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(services => new CompositeMeetingAudioSource( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService>())); builder.Services.AddSingleton(); #else builder.Services.AddSingleton(); builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); #if WINDOWS builder.Services.AddSingleton(); #else builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddDbContextFactory((services, dbOptions) => { var appOptions = services.GetRequiredService>().Value; var databasePath = VaultPath.Resolve(appOptions.SpeakerIdentification.DatabasePath); Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); dbOptions.UseSqlite($"Data Source={databasePath}"); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); #if WINDOWS builder.Services.AddSingleton(); #else builder.Services.AddSingleton(); #endif builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); #if WINDOWS builder.Services.AddHostedService(); builder.Services.AddHostedService(); #endif var app = builder.Build(); app.MapGet("/health", () => Results.Ok(new { service = "meeting-assistant", status = "ok" })); 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 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 options, ILaunchProfileOptionsProvider launchProfiles, DateTimeOffset? startedAt, double? timeoutSeconds, CancellationToken cancellationToken) => await DiagnoseOutlookMeetingAsync( launchProfile, metadataProvider, options.Value, launchProfiles, startedAt, timeoutSeconds, cancellationToken)); static async Task 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) : profileOptions.Recording.MetadataLookupTimeout; if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics) { return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync( lookupStartedAt, timeout, cancellationToken)); } var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var metadata = await metadataProvider.GetCurrentMeetingAsync(lookupStartedAt, cancellationToken); stopwatch.Stop(); return Results.Ok(new MeetingMetadataDiagnosticResult( metadataProvider.GetType().Name, lookupStartedAt, metadata is not null, false, 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 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 options, ILaunchProfileOptionsProvider launchProfiles, CancellationToken cancellationToken) => await MergeSpeakerIdentitiesAsync(launchProfile, request, mergeService, options.Value, launchProfiles, cancellationToken)); static async Task 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) : profileOptions.SpeakerIdentification.MergeRecentIdentityAge; return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken)); } app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) => Results.Ok(ReloadWorkflowConfiguration(configuration))); app.MapPost("/diagnostics/workflow/rules-editor/show", ( IWorkflowRulesEditorWindowService rulesEditorWindow) => { rulesEditorWindow.Show(); return Results.Accepted(); }); static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration) { if (configuration is not IConfigurationRoot root) { throw new InvalidOperationException("Application configuration does not support reload."); } root.Reload(); var options = new MeetingAssistantOptions(); configuration.GetSection("MeetingAssistant").Bind(options); return new WorkflowConfigurationReloadResponse(options.Automation.RulesPath); } 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("/recording/abort", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) => Results.Ok(await coordinator.AbortAsync(cancellationToken))); app.MapPost("/profiles/{launchProfile}/recording/abort", async ( string launchProfile, MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) => Results.Ok(await coordinator.AbortAsync(cancellationToken))); app.MapPost("/meetings/current/summary/run", async ( MeetingRecordingCoordinator coordinator, IMeetingSummaryPipeline summaryPipeline, IOptions 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 options, ILaunchProfileOptionsProvider launchProfiles, CancellationToken cancellationToken) => await RunCurrentSummaryAsync( launchProfile, coordinator, summaryPipeline, options.Value, launchProfiles, cancellationToken)); static async Task 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." }); } var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles); return Results.Ok(await summaryPipeline.RunAsync(coordinator.CurrentArtifacts, profileOptions, cancellationToken)); } app.MapPost("/meetings/summary/retry", async ( SummaryRetryRequest request, IMeetingSummaryRetryRunner summaryRetryRunner, IOptions options, CancellationToken cancellationToken) => await RetrySummaryAsync( null, request.SummaryPath, summaryRetryRunner, options.Value, null, cancellationToken)); app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async ( string launchProfile, SummaryRetryRequest request, IMeetingSummaryRetryRunner summaryRetryRunner, IOptions options, ILaunchProfileOptionsProvider launchProfiles, CancellationToken cancellationToken) => await RetrySummaryAsync( launchProfile, request.SummaryPath, summaryRetryRunner, options.Value, launchProfiles, cancellationToken)); app.MapGet("/meetings/summary/retry", async ( string summaryPath, IMeetingSummaryRetryRunner summaryRetryRunner, IOptions options, CancellationToken cancellationToken) => await RetrySummaryAsync( null, summaryPath, summaryRetryRunner, options.Value, null, cancellationToken)); app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async ( string launchProfile, string summaryPath, IMeetingSummaryRetryRunner summaryRetryRunner, IOptions options, ILaunchProfileOptionsProvider launchProfiles, CancellationToken cancellationToken) => await RetrySummaryAsync( launchProfile, summaryPath, summaryRetryRunner, options.Value, launchProfiles, cancellationToken)); static async Task RetrySummaryAsync( string? launchProfile, string? summaryPath, IMeetingSummaryRetryRunner summaryRetryRunner, MeetingAssistantOptions defaultOptions, ILaunchProfileOptionsProvider? launchProfiles, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(summaryPath)) { return Results.BadRequest(new { error = "A summary path is required." }); } var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles); var trigger = await summaryRetryRunner.TriggerAsync(summaryPath, profileOptions, cancellationToken); if (trigger is null) { return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." }); } return Results.Accepted(value: new { summaryPath = trigger.SummaryPath, assistantContextPath = trigger.AssistantContextPath, state = "summarizing" }); } 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 TranscribeFileAsync( string? launchProfile, AsrDiagnosticRequest request, AsrDiagnosticService diagnostics, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Path)) { return Results.BadRequest(new { error = "A WAV file path is required." }); } try { return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, launchProfile, cancellationToken)); } catch (FileNotFoundException exception) { return Results.NotFound(new { error = exception.Message }); } catch (InvalidDataException exception) { return Results.BadRequest(new { error = exception.Message }); } catch (Exception exception) { return Results.Problem( detail: exception.Message, 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 DiarizeFileAsync( string? launchProfile, AsrDiagnosticRequest request, AsrDiagnosticService diagnostics, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.Path)) { return Results.BadRequest(new { error = "A WAV file path is required." }); } var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(request.Path)); if (!File.Exists(fullPath)) { return Results.NotFound(new { error = $"WAV file was not found at '{fullPath}'." }); } try { return Results.Ok(await diagnostics.DiarizeWavAsync( fullPath, new SpeechRecognitionPipelineOptions(request.NumSpeakers), launchProfile, cancellationToken)); } catch (Exception exception) { return Results.Problem( detail: exception.Message, statusCode: StatusCodes.Status502BadGateway, title: "ASR diarization backend failed while processing the WAV file."); } } try { app.Run(); } catch (TimeoutException exception) { Console.Error.WriteLine( $"Meeting Assistant failed to start because a required operation timed out: {exception.Message}"); Environment.ExitCode = 1; } public partial class Program; public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null); public sealed record SummaryRetryRequest(string SummaryPath); public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null); public sealed record WorkflowConfigurationReloadResponse(string? RulesPath);