using System.Collections; using System.Globalization; using System.Text.RegularExpressions; using MeetingAssistant.MeetingNotes; using NCalc; namespace MeetingAssistant.Workflow; public interface IMeetingWorkflowEngine { Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken); Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken); Task TransformAttendeeAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(workflowEvent.AttendeeName ?? ""); } } public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine { public static NoopMeetingWorkflowEngine Instance { get; } = new(); public Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.CompletedTask; } public Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); } public Task TransformAttendeeAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { return Task.FromResult(workflowEvent.AttendeeName ?? ""); } } 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", MeetingWorkflowRuleSchema.PropertyTranscriptLine, MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker, MeetingWorkflowRuleSchema.PropertyAttendeeName ]; private readonly IMeetingWorkflowRulesProvider rulesProvider; private readonly IMeetingNoteStore meetingNoteStore; private readonly IMeetingArtifactStore meetingArtifactStore; private readonly ILogger logger; private readonly MeetingWorkflowTemplateRenderer templateRenderer = new(); public MeetingWorkflowEngine( IMeetingWorkflowRulesProvider rulesProvider, IMeetingNoteStore meetingNoteStore, IMeetingArtifactStore meetingArtifactStore, ILogger logger) { this.rulesProvider = rulesProvider; this.meetingNoteStore = meetingNoteStore; this.meetingArtifactStore = meetingArtifactStore; this.logger = logger; } public async Task RunAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { _ = await RunCoreAsync(workflowEvent, options, cancellationToken); } public async Task TransformTranscriptLineAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { if (workflowEvent.Type != MeetingWorkflowEventType.TranscriptLine) { throw new ArgumentException("Workflow event must be a transcript line event.", nameof(workflowEvent)); } return await RunCoreAsync(workflowEvent, options, cancellationToken) ?? workflowEvent.TranscriptLineText ?? ""; } public async Task TransformAttendeeAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { if (workflowEvent.Type != MeetingWorkflowEventType.AttendeeAdded) { throw new ArgumentException("Workflow event must be an attendee added event.", nameof(workflowEvent)); } return await RunCoreAsync(workflowEvent, options, cancellationToken) ?? workflowEvent.AttendeeName ?? ""; } private async Task RunCoreAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken) { var rules = await rulesProvider.GetRulesAsync(options, cancellationToken); var context = new MeetingWorkflowExecutionContext(workflowEvent); if (rules.Count == 0) { return context.ResultValue; } var meeting = await meetingNoteStore.ReadAsync( workflowEvent.Artifacts.MeetingNotePath, cancellationToken); var noteChanged = false; foreach (var rule in rules) { try { if (!MatchesTrigger(rule, workflowEvent) || !EvaluateConditions(rule.If, meeting, context)) { continue; } logger.LogInformation( "Triggered meeting workflow rule {RuleName} for event {EventType}", rule.Name, workflowEvent.Type); var ruleNoteChanged = false; var originalTranscriptLine = context.TranscriptLine; var model = CreateTemplateModel(meeting, context); foreach (var step in rule.Steps) { if (workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded && !IsAttendeeTransformStep(step)) { throw new InvalidOperationException( "attendee_added workflow rules can only transform attendee.name with set_property."); } var stepChanged = await ApplyStepAsync( step, meeting, context, options, model, cancellationToken); ruleNoteChanged |= stepChanged; noteChanged |= stepChanged; model = CreateTemplateModel(meeting, context); } logger.LogInformation( "Completed meeting workflow rule {RuleName} for event {EventType}; noteChanged={NoteChanged}, transcriptLineChanged={TranscriptLineChanged}", rule.Name, workflowEvent.Type, ruleNoteChanged, !string.Equals(originalTranscriptLine, context.TranscriptLine, StringComparison.Ordinal)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { logger.LogError( exception, "Meeting workflow rule {RuleName} failed for event {EventType}", rule.Name, workflowEvent.Type); throw; } } if (noteChanged) { var saved = await meetingNoteStore.SaveAsync(meeting, options, cancellationToken); await meetingArtifactStore.UpdateAssistantContextMeetingAsync( workflowEvent.Artifacts, saved, cancellationToken); } return context.ResultValue; } 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)); } if (trigger.TranscriptLine is not null) { return workflowEvent.Type == MeetingWorkflowEventType.TranscriptLine && (string.IsNullOrWhiteSpace(trigger.TranscriptLine.Speaker) || string.Equals( trigger.TranscriptLine.Speaker.Trim(), workflowEvent.SpeakerName, StringComparison.OrdinalIgnoreCase)); } if (trigger.AttendeeAdded is not null) { return workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded && MatchesAttendeeAddedTrigger(trigger.AttendeeAdded, workflowEvent.AttendeeName); } return false; } private static bool MatchesAttendeeAddedTrigger( MeetingWorkflowAttendeeAddedTrigger trigger, string? attendeeName) { var attendee = attendeeName ?? ""; if (!string.IsNullOrWhiteSpace(trigger.EqualsValue) && !string.Equals(attendee, trigger.EqualsValue.Trim(), StringComparison.OrdinalIgnoreCase)) { return false; } if (!string.IsNullOrWhiteSpace(trigger.Contains) && !attendee.Contains(trigger.Contains.Trim(), StringComparison.OrdinalIgnoreCase)) { return false; } if (!string.IsNullOrWhiteSpace(trigger.Regex) && !Regex.IsMatch(attendee, trigger.Regex, RegexOptions.IgnoreCase)) { return false; } return true; } private bool EvaluateConditions( IReadOnlyList conditions, MeetingNote meeting, MeetingWorkflowExecutionContext context) { return conditions.Count == 0 || conditions.All(condition => EvaluateCondition(condition, meeting, context)); } private bool EvaluateCondition( MeetingWorkflowCondition condition, MeetingNote meeting, MeetingWorkflowExecutionContext context) { if (!string.IsNullOrWhiteSpace(condition.Condition)) { return EvaluateExpression(condition.Condition, meeting, context); } if (condition.And is { Count: > 0 }) { return condition.And.All(child => EvaluateCondition(child, meeting, context)); } if (condition.Or is { Count: > 0 }) { return condition.Or.Any(child => EvaluateCondition(child, meeting, context)); } if (condition.Not is not null) { return !EvaluateCondition(condition.Not, meeting, context); } return true; } private bool EvaluateExpression( string expressionText, MeetingNote meeting, MeetingWorkflowExecutionContext context) { var expression = new Expression(PrepareExpression(expressionText)); foreach (var (name, value) in BuildParameters(meeting, context)) { expression.Parameters[name] = value; } expression.Functions["contains"] = args => Contains(args.Evaluate(0), Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture)); expression.Functions["starts_with"] = args => Convert.ToString(args.Evaluate(0), CultureInfo.InvariantCulture)?.StartsWith( Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture) ?? "", StringComparison.OrdinalIgnoreCase) == true; expression.Functions["ends_with"] = args => Convert.ToString(args.Evaluate(0), CultureInfo.InvariantCulture)?.EndsWith( Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture) ?? "", StringComparison.OrdinalIgnoreCase) == true; return Convert.ToBoolean(expression.Evaluate(), CultureInfo.InvariantCulture); } private async Task ApplyStepAsync( MeetingWorkflowStep step, MeetingNote meeting, MeetingWorkflowExecutionContext context, MeetingAssistantOptions options, MeetingWorkflowTemplateModel model, CancellationToken cancellationToken) { var value = await RenderValueAsync(step.Value ?? "", model); switch (step.Uses.Trim().ToLowerInvariant()) { case MeetingWorkflowRuleSchema.StepAddAttendee: var attendee = await TransformAttendeeAsync( MeetingWorkflowEvent.AttendeeAdded(context.Event.Artifacts, value), options, cancellationToken); return AddUnique(meeting.Frontmatter.Attendees, attendee, MeetingAttendeeNames.NormalizeDisplayName); case MeetingWorkflowRuleSchema.StepRemoveAttendee: return RemoveValue(meeting.Frontmatter.Attendees, value); case MeetingWorkflowRuleSchema.StepAddProject: return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim()); case MeetingWorkflowRuleSchema.StepAddContext: await meetingArtifactStore.AppendAssistantContextAsync( context.Event.Artifacts, value, cancellationToken); return false; case MeetingWorkflowRuleSchema.StepSetProperty when IsTranscriptLineProperty(step.Property ?? step.Name): SetTranscriptLine(context, value); return false; case MeetingWorkflowRuleSchema.StepSetProperty when IsAttendeeNameProperty(step.Property ?? step.Name): SetAttendeeName(context, value); return false; case MeetingWorkflowRuleSchema.StepSetProperty: return SetProperty(meeting, step.Property ?? step.Name, value); default: throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'."); } } private async Task RenderValueAsync( string template, MeetingWorkflowTemplateModel model) { return await templateRenderer.RenderAsync(template, model); } private static bool SetProperty( MeetingNote meeting, string? property, string value) { var normalized = property?.Trim().ToLowerInvariant(); if (normalized is MeetingWorkflowRuleSchema.PropertyTitle or MeetingWorkflowRuleSchema.PropertyMeetingTitle) { 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 void SetTranscriptLine( MeetingWorkflowExecutionContext context, string value) { if (context.Event.Type != MeetingWorkflowEventType.TranscriptLine) { throw new InvalidOperationException("The transcript.line property can only be set during transcript_line events."); } if (string.Equals(context.TranscriptLine, value, StringComparison.Ordinal)) { return; } context.TranscriptLine = value; } private static void SetAttendeeName( MeetingWorkflowExecutionContext context, string value) { if (context.Event.Type != MeetingWorkflowEventType.AttendeeAdded) { throw new InvalidOperationException("The attendee.name property can only be set during attendee_added events."); } if (string.Equals(context.AttendeeName, value, StringComparison.Ordinal)) { return; } context.AttendeeName = value; } private static bool IsTranscriptLineProperty(string? property) { return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine); } private static bool IsAttendeeNameProperty(string? property) { return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName); } private static bool IsAttendeeTransformStep(MeetingWorkflowStep step) { return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) && IsAttendeeNameProperty(step.Property ?? step.Name); } private static bool AddUnique( List values, string value, Func normalize) { var normalized = normalize(value); if (string.IsNullOrWhiteSpace(normalized) || values.Any(existing => string.Equals( normalize(existing), normalized, StringComparison.OrdinalIgnoreCase))) { return false; } values.Add(normalized); return true; } private static bool RemoveValue(List 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() .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, $@"(? BuildParameters( MeetingNote meeting, MeetingWorkflowExecutionContext context) { var workflowEvent = context.Event; return new Dictionary(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, [MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "", [MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? "", [MeetingWorkflowRuleSchema.PropertyAttendeeName] = context.AttendeeName ?? "" }; } private static MeetingWorkflowTemplateModel CreateTemplateModel( MeetingNote meeting, MeetingWorkflowExecutionContext context) { var workflowEvent = context.Event; 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), new MeetingWorkflowTranscriptModel( context.TranscriptLine ?? "", workflowEvent.SpeakerName ?? ""), string.IsNullOrWhiteSpace(context.AttendeeName) ? null : new MeetingWorkflowAttendeeModel(context.AttendeeName)); } private sealed class MeetingWorkflowExecutionContext { public MeetingWorkflowExecutionContext(MeetingWorkflowEvent workflowEvent) { Event = workflowEvent; TranscriptLine = workflowEvent.TranscriptLineText; AttendeeName = workflowEvent.AttendeeName; } public MeetingWorkflowEvent Event { get; } public string? TranscriptLine { get; set; } public string? AttendeeName { get; set; } public string? ResultValue { get { return Event.Type == MeetingWorkflowEventType.AttendeeAdded ? AttendeeName : TranscriptLine; } } } }