Add meeting workflow automation

This commit is contained in:
2026-05-27 12:55:18 +02:00
parent e85274829a
commit b114071957
29 changed files with 1703 additions and 12 deletions
@@ -5,6 +5,7 @@ using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Recording;
@@ -24,6 +25,7 @@ public sealed class MeetingRecordingCoordinator
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
private readonly IDictationWordStore dictationWordStore;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly MeetingAssistantOptions options;
private readonly ILogger<MeetingRecordingCoordinator> logger;
private readonly SemaphoreSlim gate = new(1, 1);
@@ -47,7 +49,8 @@ public sealed class MeetingRecordingCoordinator
ISpeakerIdentificationService? speakerIdentificationService = null,
IDictationWordStore? dictationWordStore = null,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
ILaunchProfileOptionsProvider? launchProfiles = null)
ILaunchProfileOptionsProvider? launchProfiles = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
{
this.audioSource = audioSource;
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
@@ -62,6 +65,7 @@ public sealed class MeetingRecordingCoordinator
this.speakerIdentificationService = speakerIdentificationService;
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
this.launchProfiles = launchProfiles;
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
this.options = options.Value;
this.logger = logger;
}
@@ -130,6 +134,15 @@ public sealed class MeetingRecordingCoordinator
assistantContextPath,
summaryPath);
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.Created(currentArtifacts),
runOptions,
cancellationToken);
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
currentArtifacts,
currentMeetingNote,
cancellationToken);
await transcriptStore.UpdateMetadataAsync(
currentSession,
currentArtifacts,
@@ -407,6 +420,7 @@ public sealed class MeetingRecordingCoordinator
run.MarkLiveIdentificationAttempted(checkpoint);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches,
run.Artifacts,
run.MeetingNotePath,
run.Options,
cancellationToken);
@@ -607,6 +621,7 @@ public sealed class MeetingRecordingCoordinator
finishedSegments,
run.GetSpeakerSamplesSnapshot(),
run.GetSpeakerMappingsSnapshot(),
run.Artifacts,
run.MeetingNotePath,
run.Options,
cancellationToken);
@@ -618,6 +633,7 @@ public sealed class MeetingRecordingCoordinator
IReadOnlyList<TranscriptionSegment> finishedSegments,
IReadOnlyList<SpeakerAudioSample> samples,
IReadOnlyDictionary<string, string> knownSpeakerMappings,
MeetingSessionArtifacts artifacts,
string meetingNotePath,
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
@@ -640,6 +656,7 @@ public sealed class MeetingRecordingCoordinator
cancellationToken);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches,
artifacts,
meetingNotePath,
runOptions,
cancellationToken);
@@ -658,6 +675,7 @@ public sealed class MeetingRecordingCoordinator
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
MeetingSessionArtifacts artifacts,
string meetingNotePath,
MeetingAssistantOptions runOptions,
CancellationToken cancellationToken)
@@ -685,7 +703,17 @@ public sealed class MeetingRecordingCoordinator
.Append(match.DisplayName)
.Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (RemoveDuplicateAcceptedAliases(meetingNote.Frontmatter.Attendees, match.DisplayName, acceptedNames))
{
existingNames = meetingNote.Frontmatter.Attendees
.Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
changed = true;
}
if (acceptedNames.Any(existingNames.Contains))
{
continue;
@@ -697,20 +725,31 @@ public sealed class MeetingRecordingCoordinator
changed = true;
}
if (!changed)
if (changed)
{
return;
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
{
currentMeetingNote = savedMeetingNote;
}
logger.LogInformation(
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
savedMeetingNote.Path);
}
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
foreach (var match in matches)
{
currentMeetingNote = savedMeetingNote;
}
if (string.IsNullOrWhiteSpace(match.DisplayName))
{
continue;
}
logger.LogInformation(
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
savedMeetingNote.Path);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.SpeakerIdentified(artifacts, match.DisplayName.Trim()),
runOptions,
cancellationToken);
}
}
private async Task<MeetingNote?> CompleteMeetingNoteAsync(
@@ -739,6 +778,59 @@ public sealed class MeetingRecordingCoordinator
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
}
private static bool RemoveDuplicateAcceptedAliases(
List<string> attendees,
string displayName,
IReadOnlyCollection<string> acceptedNames)
{
var normalizedDisplayName = NormalizeAttendeeName(displayName);
if (string.IsNullOrWhiteSpace(normalizedDisplayName) ||
!attendees.Any(attendee => string.Equals(
NormalizeAttendeeName(attendee),
normalizedDisplayName,
StringComparison.OrdinalIgnoreCase)))
{
return false;
}
var changed = false;
var displayNameKept = false;
var cleaned = new List<string>();
foreach (var attendee in attendees)
{
var normalizedAttendee = NormalizeAttendeeName(attendee);
if (string.Equals(normalizedAttendee, normalizedDisplayName, StringComparison.OrdinalIgnoreCase))
{
if (displayNameKept)
{
changed = true;
continue;
}
displayNameKept = true;
cleaned.Add(attendee);
continue;
}
if (acceptedNames.Contains(normalizedAttendee, StringComparer.OrdinalIgnoreCase))
{
changed = true;
continue;
}
cleaned.Add(attendee);
}
if (!changed)
{
return false;
}
attendees.Clear();
attendees.AddRange(cleaned);
return true;
}
private async Task RunSummaryAsync(
RecordingRun run,
CancellationToken cancellationToken)
@@ -774,7 +866,7 @@ public sealed class MeetingRecordingCoordinator
AssistantContextState state,
CancellationToken cancellationToken)
{
if (!run.TryTransitionTo(state))
if (!run.TryTransitionTo(state, out var fromState))
{
return;
}
@@ -783,6 +875,10 @@ public sealed class MeetingRecordingCoordinator
run.Artifacts,
state,
cancellationToken);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.StateTransition(run.Artifacts, fromState, state),
run.Options,
cancellationToken);
}
private bool HasConfiguredFinalizer(MeetingAssistantOptions runOptions)
@@ -959,15 +1055,19 @@ public sealed class MeetingRecordingCoordinator
LiveIdentificationCancellationSource.Cancel();
}
public bool TryTransitionTo(AssistantContextState state)
public bool TryTransitionTo(
AssistantContextState state,
out AssistantContextState fromState)
{
lock (stateGate)
{
if (StateRank(state) <= StateRank(ContextState))
{
fromState = ContextState;
return false;
}
fromState = ContextState;
ContextState = state;
return true;
}