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); } 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 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) { 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 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 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, MeetingAttendeeNames.NormalizeDisplayName); case "remove_attendee": return RemoveValue(meeting.Frontmatter.Attendees, value); case "add_project": return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim()); 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 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 "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 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, MeetingWorkflowEvent workflowEvent) { 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 }; } 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)); } }