Add attendee transformation workflows

This commit is contained in:
2026-07-01 11:10:36 +02:00
parent 92e359646b
commit 5c67738939
23 changed files with 694 additions and 123 deletions
@@ -17,6 +17,14 @@ public interface IMeetingWorkflowEngine
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
@@ -38,6 +46,14 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
{
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
@@ -54,7 +70,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"state.to",
"speaker.name",
MeetingWorkflowRuleSchema.PropertyTranscriptLine,
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker,
MeetingWorkflowRuleSchema.PropertyAttendeeName
];
private readonly IMeetingWorkflowRulesProvider rulesProvider;
@@ -98,6 +115,21 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
?? "";
}
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,
@@ -107,7 +139,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
var context = new MeetingWorkflowExecutionContext(workflowEvent);
if (rules.Count == 0)
{
return context.TranscriptLine;
return context.ResultValue;
}
var meeting = await meetingNoteStore.ReadAsync(
@@ -134,10 +166,18 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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;
@@ -176,7 +216,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
cancellationToken);
}
return context.TranscriptLine;
return context.ResultValue;
}
private static bool MatchesTrigger(
@@ -229,9 +269,41 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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,
@@ -298,6 +370,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
MeetingWorkflowStep step,
MeetingNote meeting,
MeetingWorkflowExecutionContext context,
MeetingAssistantOptions options,
MeetingWorkflowTemplateModel model,
CancellationToken cancellationToken)
{
@@ -305,7 +378,11 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
switch (step.Uses.Trim().ToLowerInvariant())
{
case MeetingWorkflowRuleSchema.StepAddAttendee:
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
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:
@@ -319,6 +396,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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:
@@ -370,11 +450,39 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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,
@@ -456,7 +564,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
["speaker.name"] = workflowEvent.SpeakerName,
[MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "",
[MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? ""
[MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? "",
[MeetingWorkflowRuleSchema.PropertyAttendeeName] = context.AttendeeName ?? ""
};
}
@@ -480,7 +589,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName),
new MeetingWorkflowTranscriptModel(
context.TranscriptLine ?? "",
workflowEvent.SpeakerName ?? ""));
workflowEvent.SpeakerName ?? ""),
string.IsNullOrWhiteSpace(context.AttendeeName)
? null
: new MeetingWorkflowAttendeeModel(context.AttendeeName));
}
private sealed class MeetingWorkflowExecutionContext
@@ -489,10 +601,23 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
{
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;
}
}
}
}
@@ -7,7 +7,8 @@ public enum MeetingWorkflowEventType
Created,
StateTransition,
SpeakerIdentified,
TranscriptLine
TranscriptLine,
AttendeeAdded
}
public sealed record MeetingWorkflowEvent(
@@ -16,7 +17,8 @@ public sealed record MeetingWorkflowEvent(
AssistantContextState? FromState = null,
AssistantContextState? ToState = null,
string? SpeakerName = null,
string? TranscriptLineText = null)
string? TranscriptLineText = null,
string? AttendeeName = null)
{
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
{
@@ -49,4 +51,14 @@ public sealed record MeetingWorkflowEvent(
SpeakerName: speakerName,
TranscriptLineText: line);
}
public static MeetingWorkflowEvent AttendeeAdded(
MeetingSessionArtifacts artifacts,
string attendeeName)
{
return new MeetingWorkflowEvent(
MeetingWorkflowEventType.AttendeeAdded,
artifacts,
AttendeeName: attendeeName);
}
}
@@ -37,6 +37,9 @@ public sealed class MeetingWorkflowTrigger
[YamlMember(Alias = "transcript_line")]
public MeetingWorkflowTranscriptLineTrigger? TranscriptLine { get; set; }
[YamlMember(Alias = "attendee_added")]
public MeetingWorkflowAttendeeAddedTrigger? AttendeeAdded { get; set; }
}
public sealed class MeetingWorkflowStateTransitionTrigger
@@ -60,6 +63,18 @@ public sealed class MeetingWorkflowTranscriptLineTrigger
public string? Speaker { get; set; }
}
public sealed class MeetingWorkflowAttendeeAddedTrigger
{
[YamlMember(Alias = "equals")]
public string? EqualsValue { get; set; }
[YamlMember(Alias = "contains")]
public string? Contains { get; set; }
[YamlMember(Alias = "regex")]
public string? Regex { get; set; }
}
public sealed class MeetingWorkflowCondition
{
[YamlMember(Alias = "condition")]
@@ -94,7 +109,8 @@ public sealed record MeetingWorkflowTemplateModel(
MeetingWorkflowMeetingModel Meeting,
MeetingWorkflowEventModel Event,
MeetingWorkflowSpeakerModel? Speaker,
MeetingWorkflowTranscriptModel? Transcript);
MeetingWorkflowTranscriptModel? Transcript,
MeetingWorkflowAttendeeModel? Attendee);
public sealed record MeetingWorkflowMeetingModel(
string Title,
@@ -111,6 +127,8 @@ public sealed record MeetingWorkflowSpeakerModel(string Name);
public sealed record MeetingWorkflowTranscriptModel(string Line, string Speaker);
public sealed record MeetingWorkflowAttendeeModel(string Name);
internal static class MeetingWorkflowStateNames
{
public static string ToRuleName(AssistantContextState state)
@@ -12,13 +12,15 @@ internal static class MeetingWorkflowRuleSchema
public const string PropertyMeetingTitle = "meeting.title";
public const string PropertyTranscriptLine = "transcript.line";
public const string ParameterTranscriptSpeaker = "transcript.speaker";
public const string PropertyAttendeeName = "attendee.name";
public static readonly string[] SupportedTriggerKeys =
[
"created",
"state_transition",
"speaker_identified",
"transcript_line"
"transcript_line",
"attendee_added"
];
public static readonly string[] SupportedConditionKeys =
@@ -42,7 +44,8 @@ internal static class MeetingWorkflowRuleSchema
[
PropertyTitle,
PropertyMeetingTitle,
PropertyTranscriptLine
PropertyTranscriptLine,
PropertyAttendeeName
];
public static bool IsStep(string? value, string step)
@@ -59,7 +59,8 @@ internal static class MeetingWorkflowRulesValidator
trigger.Created is not null,
trigger.StateTransition is not null,
trigger.SpeakerIdentified is not null,
trigger.TranscriptLine is not null);
trigger.TranscriptLine is not null,
trigger.AttendeeAdded is not null);
if (configuredCount == 0)
{
errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedTriggerKeys)}.");
@@ -137,6 +138,12 @@ internal static class MeetingWorkflowRulesValidator
return;
}
if (HasAttendeeAddedTrigger(rule) &&
!IsAttendeeNameSetPropertyStep(step))
{
errors.Add($"{stepPath} on attendee_added rules can only transform the attendee with 'set_property' property 'attendee.name'.");
}
if (MeetingWorkflowRuleSchema.IsStep(uses, MeetingWorkflowRuleSchema.StepSetProperty))
{
var property = step.Property ?? step.Name;
@@ -153,6 +160,11 @@ internal static class MeetingWorkflowRulesValidator
{
errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger.");
}
else if (MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName) &&
!HasAttendeeAddedTrigger(rule))
{
errors.Add($"{stepPath} can set 'attendee.name' only on rules with an attendee_added trigger.");
}
}
if (step.Value is { } value &&
@@ -181,6 +193,17 @@ internal static class MeetingWorkflowRulesValidator
return rule.On.Any(static trigger => trigger.TranscriptLine is not null);
}
private static bool HasAttendeeAddedTrigger(MeetingWorkflowRule rule)
{
return rule.On.Any(static trigger => trigger.AttendeeAdded is not null);
}
private static bool IsAttendeeNameSetPropertyStep(MeetingWorkflowStep step)
{
return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) &&
MeetingWorkflowRuleSchema.IsProperty(step.Property ?? step.Name, MeetingWorkflowRuleSchema.PropertyAttendeeName);
}
private static int CountConfigured(params bool[] values)
{
return values.Count(static value => value);
@@ -206,7 +229,8 @@ internal static class MeetingWorkflowRulesValidator
new MeetingWorkflowSpeakerModel("Ada Lovelace"),
new MeetingWorkflowTranscriptModel(
"[00:00:01] Ada Lovelace: Validation line.",
"Ada Lovelace"));
"Ada Lovelace"),
new MeetingWorkflowAttendeeModel("Ada Lovelace"));
}
private static string FormatRazorValidationMessage(Exception exception)