using MeetingAssistant; using MeetingAssistant.Hotkeys; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Recording; using MeetingAssistant.Speakers; using MeetingAssistant.Summary; using MeetingAssistant.Transcription; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("MeetingAssistant")); #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.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.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); #if WINDOWS 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("/diagnostics/outlook/current-meeting", async ( IMeetingMetadataProvider metadataProvider, IOptions options, DateTimeOffset? startedAt, double? timeoutSeconds, CancellationToken cancellationToken) => { var lookupStartedAt = startedAt ?? DateTimeOffset.Now; var timeout = timeoutSeconds is > 0 ? TimeSpan.FromSeconds(timeoutSeconds.Value) : options.Value.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)); }); app.MapPost("/diagnostics/speaker-identities/merge", async ( SpeakerIdentityMergeDiagnosticRequest request, ISpeakerIdentityMergeService mergeService, IOptions options, CancellationToken cancellationToken) => { var recentAge = request.RecentDays is > 0 ? TimeSpan.FromDays(request.RecentDays.Value) : options.Value.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("/recording/start", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) => Results.Ok(await coordinator.StartAsync(cancellationToken))); app.MapPost("/recording/stop", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) => Results.Ok(await coordinator.StopAsync(cancellationToken))); app.MapPost("/meetings/current/summary/run", async ( MeetingRecordingCoordinator coordinator, IMeetingSummaryPipeline summaryPipeline, 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)); }); app.MapPost("/meetings/summary/retry", async ( SummaryRetryRequest request, IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryPipeline summaryPipeline, 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)); }); app.MapGet("/meetings/summary/retry", async ( string summaryPath, IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryPipeline summaryPipeline, CancellationToken cancellationToken) => { if (string.IsNullOrWhiteSpace(summaryPath)) { return Results.BadRequest(new { error = "A summary path is required." }); } var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, 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)); }); app.MapPost("/asr/transcribe-file", async ( 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, 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) => { 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), 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);