Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -8,6 +8,12 @@ public interface IMeetingArtifactStore
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
|
||||
@@ -7,12 +7,29 @@ public interface IMeetingMetadataProvider
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IMeetingMetadataDiagnosticProvider
|
||||
{
|
||||
Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingMetadata(
|
||||
string Title,
|
||||
IReadOnlyList<string> Attendees,
|
||||
string Agenda,
|
||||
DateTimeOffset? ScheduledEnd = null);
|
||||
|
||||
public sealed record MeetingMetadataDiagnosticResult(
|
||||
string Provider,
|
||||
DateTimeOffset StartedAt,
|
||||
bool Succeeded,
|
||||
bool TimedOut,
|
||||
long ElapsedMilliseconds,
|
||||
MeetingMetadata? Metadata,
|
||||
string? Error);
|
||||
|
||||
public sealed class NoopMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
|
||||
@@ -58,6 +58,28 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
ToYamlState(state));
|
||||
}
|
||||
|
||||
public async Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
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);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
Render(artifacts, state, agenda, scheduledEnd, existing.Body),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Updated assistant context note {AssistantContextPath} calendar metadata",
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
private static string Render(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
@@ -100,6 +122,9 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
|
||||
private sealed class AssistantContextFrontmatterYaml
|
||||
{
|
||||
[YamlMember(Alias = "state")]
|
||||
public string? State { get; set; }
|
||||
|
||||
[YamlMember(Alias = "agenda")]
|
||||
public string? Agenda { get; set; }
|
||||
|
||||
@@ -107,6 +132,19 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
public string? ScheduledEnd { get; set; }
|
||||
}
|
||||
|
||||
private static AssistantContextState ParseState(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"transcribing" => AssistantContextState.Transcribing,
|
||||
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
||||
"summarizing" => AssistantContextState.Summarizing,
|
||||
"finished" => AssistantContextState.Finished,
|
||||
"error" => AssistantContextState.Error,
|
||||
_ => AssistantContextState.Transcribing
|
||||
};
|
||||
}
|
||||
|
||||
private static string ToYamlState(AssistantContextState state)
|
||||
{
|
||||
return state switch
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
|
||||
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
|
||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(note.Path)
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<ObsidianMeetingNoteOpener> logger;
|
||||
|
||||
public ObsidianMeetingNoteOpener(ILogger<ObsidianMeetingNoteOpener> logger)
|
||||
public ObsidianMeetingNoteOpener(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<ObsidianMeetingNoteOpener> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
public async Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Recording.OpenMeetingNoteDelay > TimeSpan.Zero)
|
||||
{
|
||||
await Task.Delay(options.Recording.OpenMeetingNoteDelay, cancellationToken);
|
||||
}
|
||||
|
||||
var uri = $"obsidian://open?path={Uri.EscapeDataString(Path.GetFullPath(notePath))}";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
@@ -20,7 +30,5 @@ public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
|
||||
UseShellExecute = true
|
||||
});
|
||||
logger.LogInformation("Opened meeting note in Obsidian via {ObsidianUri}", uri);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Diagnostics;
|
||||
using System.Collections;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider, IMeetingMetadataDiagnosticProvider
|
||||
{
|
||||
private const int OutlookCalendarFolder = 9;
|
||||
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
|
||||
@@ -19,7 +21,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
return Task.FromResult(GetCurrentMeeting(startedAt));
|
||||
return RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
@@ -28,13 +30,66 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MeetingMetadataDiagnosticResult> DiagnoseCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var lookup = RunStaAsync(() => GetCurrentMeeting(startedAt), cancellationToken);
|
||||
var metadata = timeout > TimeSpan.Zero
|
||||
? await lookup.WaitAsync(timeout, cancellationToken)
|
||||
: await lookup.WaitAsync(cancellationToken);
|
||||
stopwatch.Stop();
|
||||
return new MeetingMetadataDiagnosticResult(
|
||||
nameof(OutlookClassicMeetingMetadataProvider),
|
||||
startedAt,
|
||||
metadata is not null,
|
||||
false,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
metadata,
|
||||
metadata is null ? "No matching Teams appointment was found." : null);
|
||||
}
|
||||
catch (TimeoutException exception)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new MeetingMetadataDiagnosticResult(
|
||||
nameof(OutlookClassicMeetingMetadataProvider),
|
||||
startedAt,
|
||||
false,
|
||||
true,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
exception.Message);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new MeetingMetadataDiagnosticResult(
|
||||
nameof(OutlookClassicMeetingMetadataProvider),
|
||||
startedAt,
|
||||
false,
|
||||
false,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
null,
|
||||
exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
|
||||
{
|
||||
EnsureOutlookRunning();
|
||||
|
||||
object? application = null;
|
||||
object? session = null;
|
||||
object? calendar = null;
|
||||
object? items = null;
|
||||
object? restrictedItems = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -53,31 +108,50 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
session = GetProperty(application, "Session");
|
||||
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
|
||||
items = GetProperty(calendar!, "Items");
|
||||
SetProperty(items!, "IncludeRecurrences", true);
|
||||
Invoke(items!, "Sort", "[Start]");
|
||||
SetProperty(items!, "IncludeRecurrences", true);
|
||||
|
||||
var startedLocal = startedAt.LocalDateTime;
|
||||
var windowStart = startedLocal.AddMinutes(-1);
|
||||
var windowStart = startedLocal.AddHours(-4);
|
||||
var windowEnd = startedLocal.AddMinutes(5);
|
||||
var filter =
|
||||
$"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'";
|
||||
restrictedItems = Invoke(items!, "Restrict", filter);
|
||||
|
||||
var candidates = new List<OutlookAppointmentCandidate>();
|
||||
var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
foreach (var item in EnumerateItems(items!))
|
||||
{
|
||||
var appointment = Invoke(restrictedItems!, "Item", index);
|
||||
if (appointment is not null && IsTeamsAppointment(appointment))
|
||||
if (item is null)
|
||||
{
|
||||
candidates.Add(new OutlookAppointmentCandidate(
|
||||
appointment,
|
||||
Convert.ToDateTime(GetProperty(appointment, "Start")),
|
||||
Convert.ToDateTime(GetProperty(appointment, "End"))));
|
||||
continue;
|
||||
}
|
||||
else
|
||||
|
||||
var keepAppointment = false;
|
||||
try
|
||||
{
|
||||
ReleaseComObject(appointment);
|
||||
if (!IsAppointment(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = Convert.ToDateTime(GetProperty(item, "Start"));
|
||||
if (start > windowEnd)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var end = Convert.ToDateTime(GetProperty(item, "End"));
|
||||
if (end < windowStart || !IsTeamsAppointment(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add(new OutlookAppointmentCandidate(item, start, end));
|
||||
keepAppointment = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!keepAppointment)
|
||||
{
|
||||
ReleaseComObject(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +192,6 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(restrictedItems);
|
||||
ReleaseComObject(items);
|
||||
ReleaseComObject(calendar);
|
||||
ReleaseComObject(session);
|
||||
@@ -126,6 +199,83 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<object?> EnumerateItems(object items)
|
||||
{
|
||||
if (items is IEnumerable enumerable)
|
||||
{
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAppointment(object item)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Convert.ToInt32(GetProperty(item, "Class")) == 26;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureOutlookRunning()
|
||||
{
|
||||
if (Process.GetProcessesByName("OUTLOOK").Length > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "outlook.exe",
|
||||
UseShellExecute = true
|
||||
});
|
||||
Thread.Sleep(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not start Outlook Classic before metadata lookup");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<T> RunStaAsync<T>(
|
||||
Func<T> action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
completion.TrySetResult(action());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
completion.TrySetException(exception);
|
||||
}
|
||||
})
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Outlook COM Lookup"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
|
||||
var registration = cancellationToken.Register(() => completion.TrySetCanceled(cancellationToken));
|
||||
_ = completion.Task.ContinueWith(
|
||||
_ => registration.Dispose(),
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
thread.Start();
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
internal static string ExtractAgenda(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
@@ -215,10 +365,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return attendees
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return NormalizeAttendees(attendees);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToLocalOffset(DateTime value)
|
||||
@@ -226,6 +373,58 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(NormalizeAttendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
|
||||
.ThenBy(attendee => attendee.Length)
|
||||
.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var normalized = attendee.Trim();
|
||||
var mailtoPrefix = "mailto:";
|
||||
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = normalized.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
var name = normalized[..emailStart].Trim();
|
||||
var email = normalized[(emailStart + 1)..emailEnd].Trim();
|
||||
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetAttendeeDeduplicationKey(string attendee)
|
||||
{
|
||||
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = attendee.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
return attendee[(emailStart + 1)..emailEnd].Trim();
|
||||
}
|
||||
|
||||
return attendee.Trim();
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
|
||||
Reference in New Issue
Block a user