Public Access
472 lines
17 KiB
C#
472 lines
17 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);
|
|
}
|
|
|
|
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 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
|
|
];
|
|
|
|
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
|
|
?? "";
|
|
}
|
|
|
|
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.TranscriptLine;
|
|
}
|
|
|
|
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, context))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Applying meeting workflow rule {RuleName} for event {EventType}",
|
|
rule.Name,
|
|
workflowEvent.Type);
|
|
var model = CreateTemplateModel(meeting, context);
|
|
foreach (var step in rule.Steps)
|
|
{
|
|
noteChanged |= await ApplyStepAsync(
|
|
step,
|
|
meeting,
|
|
context,
|
|
model,
|
|
cancellationToken);
|
|
model = CreateTemplateModel(meeting, context);
|
|
}
|
|
}
|
|
|
|
if (noteChanged)
|
|
{
|
|
var saved = await meetingNoteStore.SaveAsync(meeting, options, cancellationToken);
|
|
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
|
workflowEvent.Artifacts,
|
|
saved,
|
|
cancellationToken);
|
|
}
|
|
|
|
return context.TranscriptLine;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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[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,
|
|
MeetingWorkflowExecutionContext context,
|
|
MeetingWorkflowTemplateModel model,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var value = await RenderValueAsync(step.Value ?? "", model);
|
|
switch (step.Uses.Trim().ToLowerInvariant())
|
|
{
|
|
case MeetingWorkflowRuleSchema.StepAddAttendee:
|
|
return AddUnique(meeting.Frontmatter.Attendees, value, 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:
|
|
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 bool IsTranscriptLineProperty(string? property)
|
|
{
|
|
return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine);
|
|
}
|
|
|
|
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 ?? ""
|
|
};
|
|
}
|
|
|
|
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 ?? ""));
|
|
}
|
|
|
|
private sealed class MeetingWorkflowExecutionContext
|
|
{
|
|
public MeetingWorkflowExecutionContext(MeetingWorkflowEvent workflowEvent)
|
|
{
|
|
Event = workflowEvent;
|
|
TranscriptLine = workflowEvent.TranscriptLineText;
|
|
}
|
|
|
|
public MeetingWorkflowEvent Event { get; }
|
|
|
|
public string? TranscriptLine { get; set; }
|
|
}
|
|
}
|