Public Access
Add meeting workflow automation
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="5.12.0" />
|
||||
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||
<PackageReference Include="Whisper.net" Version="1.9.0" />
|
||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="17.1.0" />
|
||||
|
||||
@@ -16,11 +16,18 @@ public sealed class MeetingAssistantOptions
|
||||
|
||||
public SpeakerIdentificationOptions SpeakerIdentification { get; set; } = new();
|
||||
|
||||
public AutomationOptions Automation { get; set; } = new();
|
||||
|
||||
public AgentOptions Agent { get; set; } = new();
|
||||
|
||||
public ApiOptions Api { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AutomationOptions
|
||||
{
|
||||
public string? RulesPath { get; set; } = "meeting-rules.local.yaml";
|
||||
}
|
||||
|
||||
public sealed class HotkeyOptions
|
||||
{
|
||||
public string Toggle { get; set; } = "Ctrl+Alt+M";
|
||||
|
||||
@@ -25,6 +25,14 @@ public interface IMeetingArtifactStore
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task AppendAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AssistantContextState
|
||||
|
||||
@@ -107,6 +107,30 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
public async Task AppendAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
var prefix = File.Exists(artifacts.AssistantContextPath) &&
|
||||
new FileInfo(artifacts.AssistantContextPath).Length > 0
|
||||
? Environment.NewLine
|
||||
: "";
|
||||
await File.AppendAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
prefix + content.TrimEnd() + Environment.NewLine,
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Appended automation context to assistant context note {AssistantContextPath}",
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
private static string Render(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class MeetingArtifactFrontmatter
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
|
||||
public List<string>? Attendees { get; set; }
|
||||
|
||||
public string Meeting { get; set; } = "";
|
||||
|
||||
public string Transcript { get; set; } = "";
|
||||
@@ -36,6 +38,7 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
AppendScalar(builder, "title", frontmatter.Title);
|
||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
|
||||
AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting);
|
||||
AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript);
|
||||
AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext);
|
||||
@@ -142,6 +145,22 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")));
|
||||
}
|
||||
|
||||
private static void AppendListIfNotNull(StringBuilder builder, string key, IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append(key);
|
||||
builder.AppendLine(":");
|
||||
foreach (var value in values)
|
||||
{
|
||||
builder.Append("- ");
|
||||
builder.AppendLine(EscapeListItem(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendBlockScalar(StringBuilder builder, string key, string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
|
||||
@@ -6,6 +6,7 @@ using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -47,6 +48,8 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
|
||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ public sealed class MeetingSummaryTools
|
||||
meetingNote,
|
||||
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
||||
artifacts.SummaryPath);
|
||||
frontmatter.Attendees = await ResolveSummaryAttendeesAsync(meetingNote);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
||||
@@ -571,6 +572,37 @@ public sealed class MeetingSummaryTools
|
||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async Task<List<string>?> ResolveSummaryAttendeesAsync(MeetingNote meetingNote)
|
||||
{
|
||||
var existingAttendees = await ReadExistingSummaryAttendeesAsync();
|
||||
if (existingAttendees is not null)
|
||||
{
|
||||
return existingAttendees;
|
||||
}
|
||||
|
||||
return meetingNote.Frontmatter.Attendees.Count == 0
|
||||
? null
|
||||
: meetingNote.Frontmatter.Attendees.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<string>?> ReadExistingSummaryAttendeesAsync()
|
||||
{
|
||||
if (!File.Exists(artifacts.SummaryPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter);
|
||||
return yaml?.Attendees;
|
||||
}
|
||||
|
||||
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
|
||||
|
||||
private sealed record FileLineEditMode(
|
||||
@@ -633,6 +665,12 @@ public sealed class MeetingSummaryTools
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SummaryFrontmatterYaml
|
||||
{
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
|
||||
public List<string>? Attendees { get; set; }
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDateTime(string? value)
|
||||
{
|
||||
return DateTimeOffset.TryParse(value, out var parsed)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IMeetingWorkflowRulesProvider
|
||||
{
|
||||
Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProvider
|
||||
{
|
||||
private readonly ILogger<FileMeetingWorkflowRulesProvider> logger;
|
||||
private readonly IDeserializer yamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public FileMeetingWorkflowRulesProvider(ILogger<FileMeetingWorkflowRulesProvider> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Automation.RulesPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var path = ResolvePath(options.Automation.RulesPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(yaml))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rulesFile = yamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml)
|
||||
?? new MeetingWorkflowRulesFile();
|
||||
return rulesFile.Rules;
|
||||
}
|
||||
|
||||
private static string ResolvePath(string configuredPath)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using NCalc;
|
||||
using RazorLight;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IMeetingWorkflowEngine
|
||||
{
|
||||
Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
public static NoopMeetingWorkflowEngine Instance { get; } = new();
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private static readonly string[] ParameterNames =
|
||||
[
|
||||
"meeting.attendees.count",
|
||||
"meeting.attendees",
|
||||
"meeting.projects",
|
||||
"meeting.title",
|
||||
"meeting.state",
|
||||
"event.type",
|
||||
"state.from",
|
||||
"state.to",
|
||||
"speaker.name"
|
||||
];
|
||||
|
||||
private readonly IMeetingWorkflowRulesProvider rulesProvider;
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
private readonly IMeetingArtifactStore meetingArtifactStore;
|
||||
private readonly ILogger<MeetingWorkflowEngine> logger;
|
||||
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
|
||||
.UseNoProject()
|
||||
.Build();
|
||||
|
||||
public MeetingWorkflowEngine(
|
||||
IMeetingWorkflowRulesProvider rulesProvider,
|
||||
IMeetingNoteStore meetingNoteStore,
|
||||
IMeetingArtifactStore meetingArtifactStore,
|
||||
ILogger<MeetingWorkflowEngine> logger)
|
||||
{
|
||||
this.rulesProvider = rulesProvider;
|
||||
this.meetingNoteStore = meetingNoteStore;
|
||||
this.meetingArtifactStore = meetingArtifactStore;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = await rulesProvider.GetRulesAsync(options, cancellationToken);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var meeting = await meetingNoteStore.ReadAsync(
|
||||
workflowEvent.Artifacts.MeetingNotePath,
|
||||
cancellationToken);
|
||||
var noteChanged = false;
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!MatchesTrigger(rule, workflowEvent) ||
|
||||
!EvaluateConditions(rule.If, meeting, workflowEvent))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Applying meeting workflow rule {RuleName} for event {EventType}",
|
||||
rule.Name,
|
||||
workflowEvent.Type);
|
||||
var model = CreateTemplateModel(meeting, workflowEvent);
|
||||
foreach (var step in rule.Steps)
|
||||
{
|
||||
noteChanged |= await ApplyStepAsync(
|
||||
step,
|
||||
meeting,
|
||||
workflowEvent,
|
||||
model,
|
||||
cancellationToken);
|
||||
model = CreateTemplateModel(meeting, workflowEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (noteChanged)
|
||||
{
|
||||
var saved = await meetingNoteStore.SaveAsync(meeting, options, cancellationToken);
|
||||
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
||||
workflowEvent.Artifacts,
|
||||
saved,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesTrigger(
|
||||
MeetingWorkflowRule rule,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (rule.On.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.On.Any(trigger => MatchesTrigger(trigger, workflowEvent));
|
||||
}
|
||||
|
||||
private static bool MatchesTrigger(
|
||||
MeetingWorkflowTrigger trigger,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (trigger.Created is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.Created;
|
||||
}
|
||||
|
||||
if (trigger.StateTransition is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.StateTransition &&
|
||||
workflowEvent.FromState is { } from &&
|
||||
workflowEvent.ToState is { } to &&
|
||||
MeetingWorkflowStateNames.EqualsRuleName(from, trigger.StateTransition.From) &&
|
||||
MeetingWorkflowStateNames.EqualsRuleName(to, trigger.StateTransition.To);
|
||||
}
|
||||
|
||||
if (trigger.SpeakerIdentified is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.SpeakerIdentified &&
|
||||
(string.IsNullOrWhiteSpace(trigger.SpeakerIdentified.Name) ||
|
||||
string.Equals(
|
||||
trigger.SpeakerIdentified.Name.Trim(),
|
||||
workflowEvent.SpeakerName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EvaluateConditions(
|
||||
IReadOnlyList<MeetingWorkflowCondition> conditions,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return conditions.Count == 0 ||
|
||||
conditions.All(condition => EvaluateCondition(condition, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
private bool EvaluateCondition(
|
||||
MeetingWorkflowCondition condition,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(condition.Condition))
|
||||
{
|
||||
return EvaluateExpression(condition.Condition, meeting, workflowEvent);
|
||||
}
|
||||
|
||||
if (condition.And is { Count: > 0 })
|
||||
{
|
||||
return condition.And.All(child => EvaluateCondition(child, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
if (condition.Or is { Count: > 0 })
|
||||
{
|
||||
return condition.Or.Any(child => EvaluateCondition(child, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
if (condition.Not is not null)
|
||||
{
|
||||
return !EvaluateCondition(condition.Not, meeting, workflowEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EvaluateExpression(
|
||||
string expressionText,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
var expression = new Expression(PrepareExpression(expressionText));
|
||||
foreach (var (name, value) in BuildParameters(meeting, workflowEvent))
|
||||
{
|
||||
expression.Parameters[name] = value;
|
||||
}
|
||||
|
||||
expression.Functions["contains"] = args =>
|
||||
Contains(args[0].Evaluate(), Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture));
|
||||
expression.Functions["starts_with"] = args =>
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.StartsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
expression.Functions["ends_with"] = args =>
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.EndsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
return Convert.ToBoolean(expression.Evaluate(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyStepAsync(
|
||||
MeetingWorkflowStep step,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingWorkflowTemplateModel model,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await RenderValueAsync(step.Value ?? "", model);
|
||||
switch (step.Uses.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "add_attendee":
|
||||
return AddUnique(meeting.Frontmatter.Attendees, value);
|
||||
case "remove_attendee":
|
||||
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
||||
case "add_project":
|
||||
return AddUnique(meeting.Frontmatter.Projects, value);
|
||||
case "set_property":
|
||||
return SetProperty(meeting, step.Property ?? step.Name, value);
|
||||
case "add_context":
|
||||
await meetingArtifactStore.AppendAssistantContextAsync(
|
||||
workflowEvent.Artifacts,
|
||||
value,
|
||||
cancellationToken);
|
||||
return false;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RenderValueAsync(
|
||||
string template,
|
||||
MeetingWorkflowTemplateModel model)
|
||||
{
|
||||
if (!template.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
return await razorEngine.CompileRenderStringAsync(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
template,
|
||||
model);
|
||||
}
|
||||
|
||||
private static bool SetProperty(
|
||||
MeetingNote meeting,
|
||||
string? property,
|
||||
string value)
|
||||
{
|
||||
var normalized = property?.Trim().ToLowerInvariant();
|
||||
if (normalized is "title" or "meeting.title")
|
||||
{
|
||||
if (string.Equals(meeting.Frontmatter.Title, value, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
meeting.Frontmatter.Title = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported meeting workflow property '{property}'.");
|
||||
}
|
||||
|
||||
private static bool AddUnique(List<string> values, string value)
|
||||
{
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
values.Any(existing => string.Equals(existing, normalized, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
values.Add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveValue(List<string> values, string value)
|
||||
{
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
|
||||
return values.RemoveAll(existing =>
|
||||
string.Equals(
|
||||
MeetingAttendeeNames.NormalizeDisplayName(existing),
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
}
|
||||
|
||||
private static bool Contains(object? haystack, string? needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (haystack is IEnumerable enumerable and not string)
|
||||
{
|
||||
return enumerable
|
||||
.Cast<object?>()
|
||||
.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture))
|
||||
.Any(item => string.Equals(item, needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Convert.ToString(haystack, CultureInfo.InvariantCulture)?.Contains(
|
||||
needle,
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private static string PrepareExpression(string expression)
|
||||
{
|
||||
var prepared = expression;
|
||||
foreach (var parameter in ParameterNames.OrderByDescending(name => name.Length))
|
||||
{
|
||||
prepared = Regex.Replace(
|
||||
prepared,
|
||||
$@"(?<![\[\w.]){Regex.Escape(parameter)}(?![\]\w.])",
|
||||
$"[{parameter}]",
|
||||
RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> BuildParameters(
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["meeting.attendees.count"] = meeting.Frontmatter.Attendees.Count,
|
||||
["meeting.attendees"] = meeting.Frontmatter.Attendees,
|
||||
["meeting.projects"] = meeting.Frontmatter.Projects,
|
||||
["meeting.title"] = meeting.Frontmatter.Title,
|
||||
["meeting.state"] = workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : "",
|
||||
["event.type"] = workflowEvent.Type.ToString(),
|
||||
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
|
||||
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
|
||||
["speaker.name"] = workflowEvent.SpeakerName
|
||||
};
|
||||
}
|
||||
|
||||
private static MeetingWorkflowTemplateModel CreateTemplateModel(
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return new MeetingWorkflowTemplateModel(
|
||||
new MeetingWorkflowMeetingModel(
|
||||
meeting.Frontmatter.Title,
|
||||
meeting.Frontmatter.Attendees,
|
||||
meeting.Frontmatter.Projects,
|
||||
workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : ""),
|
||||
new MeetingWorkflowEventModel(
|
||||
workflowEvent.Type.ToString(),
|
||||
workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : null,
|
||||
workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : null),
|
||||
string.IsNullOrWhiteSpace(workflowEvent.SpeakerName)
|
||||
? null
|
||||
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public enum MeetingWorkflowEventType
|
||||
{
|
||||
Created,
|
||||
StateTransition,
|
||||
SpeakerIdentified
|
||||
}
|
||||
|
||||
public sealed record MeetingWorkflowEvent(
|
||||
MeetingWorkflowEventType Type,
|
||||
MeetingSessionArtifacts Artifacts,
|
||||
AssistantContextState? FromState = null,
|
||||
AssistantContextState? ToState = null,
|
||||
string? SpeakerName = null)
|
||||
{
|
||||
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.Created, artifacts);
|
||||
}
|
||||
|
||||
public static MeetingWorkflowEvent StateTransition(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState from,
|
||||
AssistantContextState to)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.StateTransition, artifacts, from, to);
|
||||
}
|
||||
|
||||
public static MeetingWorkflowEvent SpeakerIdentified(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string speakerName)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.SpeakerIdentified, artifacts, SpeakerName: speakerName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MeetingWorkflowRulesFile
|
||||
{
|
||||
[YamlMember(Alias = "rules")]
|
||||
public List<MeetingWorkflowRule> Rules { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowRule
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[YamlMember(Alias = "on")]
|
||||
public List<MeetingWorkflowTrigger> On { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "if")]
|
||||
public List<MeetingWorkflowCondition> If { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "steps")]
|
||||
public List<MeetingWorkflowStep> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowTrigger
|
||||
{
|
||||
[YamlMember(Alias = "created")]
|
||||
public object? Created { get; set; }
|
||||
|
||||
[YamlMember(Alias = "state_transition")]
|
||||
public MeetingWorkflowStateTransitionTrigger? StateTransition { get; set; }
|
||||
|
||||
[YamlMember(Alias = "speaker_identified")]
|
||||
public MeetingWorkflowSpeakerIdentifiedTrigger? SpeakerIdentified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowStateTransitionTrigger
|
||||
{
|
||||
[YamlMember(Alias = "from")]
|
||||
public string? From { get; set; }
|
||||
|
||||
[YamlMember(Alias = "to")]
|
||||
public string? To { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowSpeakerIdentifiedTrigger
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowCondition
|
||||
{
|
||||
[YamlMember(Alias = "condition")]
|
||||
public string? Condition { get; set; }
|
||||
|
||||
[YamlMember(Alias = "and")]
|
||||
public List<MeetingWorkflowCondition>? And { get; set; }
|
||||
|
||||
[YamlMember(Alias = "or")]
|
||||
public List<MeetingWorkflowCondition>? Or { get; set; }
|
||||
|
||||
[YamlMember(Alias = "not")]
|
||||
public MeetingWorkflowCondition? Not { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowStep
|
||||
{
|
||||
[YamlMember(Alias = "uses")]
|
||||
public string Uses { get; set; } = "";
|
||||
|
||||
[YamlMember(Alias = "property")]
|
||||
public string? Property { get; set; }
|
||||
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[YamlMember(Alias = "value")]
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed record MeetingWorkflowTemplateModel(
|
||||
MeetingWorkflowMeetingModel Meeting,
|
||||
MeetingWorkflowEventModel Event,
|
||||
MeetingWorkflowSpeakerModel? Speaker);
|
||||
|
||||
public sealed record MeetingWorkflowMeetingModel(
|
||||
string Title,
|
||||
IReadOnlyList<string> Attendees,
|
||||
IReadOnlyList<string> Projects,
|
||||
string State);
|
||||
|
||||
public sealed record MeetingWorkflowEventModel(
|
||||
string Type,
|
||||
string? From,
|
||||
string? To);
|
||||
|
||||
public sealed record MeetingWorkflowSpeakerModel(string Name);
|
||||
|
||||
internal static class MeetingWorkflowStateNames
|
||||
{
|
||||
public static string ToRuleName(AssistantContextState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AssistantContextState.CollectingMetadata => "collecting metadata",
|
||||
AssistantContextState.Transcribing => "transcribing",
|
||||
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
||||
AssistantContextState.Summarizing => "summarizing",
|
||||
AssistantContextState.Finished => "finished",
|
||||
AssistantContextState.Error => "error",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
public static bool EqualsRuleName(AssistantContextState state, string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ||
|
||||
string.Equals(ToRuleName(state), value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,9 @@
|
||||
"MergeRecentIdentityAge": "14.00:00:00",
|
||||
"MatchTimeout": "00:03:00"
|
||||
},
|
||||
"Automation": {
|
||||
"RulesPath": "meeting-rules.local.yaml"
|
||||
},
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
|
||||
Reference in New Issue
Block a user