Files
meeting-assistant/MeetingAssistant/Workflow/MeetingWorkflowEngine.cs
T

624 lines
22 KiB
C#

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<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
Task<string> 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<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
public Task<string> 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<MeetingWorkflowEngine> logger;
private readonly MeetingWorkflowTemplateRenderer templateRenderer = new();
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)
{
_ = await RunCoreAsync(workflowEvent, options, cancellationToken);
}
public async Task<string> 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<string> 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<string?> 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<MeetingWorkflowCondition> 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<bool> 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<string> 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<string> values,
string value,
Func<string, string> 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<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,
MeetingWorkflowExecutionContext context)
{
var workflowEvent = context.Event;
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,
[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;
}
}
}
}