Add transcript line workflow rules
PR and Push Build/Test / build-and-test (push) Failing after 13m27s

This commit is contained in:
2026-06-03 11:24:11 +02:00
parent 50daa88389
commit 40853115c5
20 changed files with 627 additions and 70 deletions
@@ -12,6 +12,11 @@ public interface IMeetingWorkflowEngine
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
@@ -25,6 +30,14 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
{
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
}
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
@@ -39,7 +52,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"event.type",
"state.from",
"state.to",
"speaker.name"
"speaker.name",
"transcript.line",
"transcript.speaker"
];
private readonly IMeetingWorkflowRulesProvider rulesProvider;
@@ -64,11 +79,35 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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;
return context.TranscriptLine;
}
var meeting = await meetingNoteStore.ReadAsync(
@@ -79,7 +118,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
foreach (var rule in rules)
{
if (!MatchesTrigger(rule, workflowEvent) ||
!EvaluateConditions(rule.If, meeting, workflowEvent))
!EvaluateConditions(rule.If, meeting, context))
{
continue;
}
@@ -88,16 +127,16 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"Applying meeting workflow rule {RuleName} for event {EventType}",
rule.Name,
workflowEvent.Type);
var model = CreateTemplateModel(meeting, workflowEvent);
var model = CreateTemplateModel(meeting, context);
foreach (var step in rule.Steps)
{
noteChanged |= await ApplyStepAsync(
step,
meeting,
workflowEvent,
context,
model,
cancellationToken);
model = CreateTemplateModel(meeting, workflowEvent);
model = CreateTemplateModel(meeting, context);
}
}
@@ -109,6 +148,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
saved,
cancellationToken);
}
return context.TranscriptLine;
}
private static bool MatchesTrigger(
@@ -151,41 +192,51 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
return conditions.Count == 0 ||
conditions.All(condition => EvaluateCondition(condition, meeting, workflowEvent));
conditions.All(condition => EvaluateCondition(condition, meeting, context));
}
private bool EvaluateCondition(
MeetingWorkflowCondition condition,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
if (!string.IsNullOrWhiteSpace(condition.Condition))
{
return EvaluateExpression(condition.Condition, meeting, workflowEvent);
return EvaluateExpression(condition.Condition, meeting, context);
}
if (condition.And is { Count: > 0 })
{
return condition.And.All(child => EvaluateCondition(child, meeting, workflowEvent));
return condition.And.All(child => EvaluateCondition(child, meeting, context));
}
if (condition.Or is { Count: > 0 })
{
return condition.Or.Any(child => EvaluateCondition(child, meeting, workflowEvent));
return condition.Or.Any(child => EvaluateCondition(child, meeting, context));
}
if (condition.Not is not null)
{
return !EvaluateCondition(condition.Not, meeting, workflowEvent);
return !EvaluateCondition(condition.Not, meeting, context);
}
return true;
@@ -194,10 +245,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private bool EvaluateExpression(
string expressionText,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var expression = new Expression(PrepareExpression(expressionText));
foreach (var (name, value) in BuildParameters(meeting, workflowEvent))
foreach (var (name, value) in BuildParameters(meeting, context))
{
expression.Parameters[name] = value;
}
@@ -219,7 +270,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private async Task<bool> ApplyStepAsync(
MeetingWorkflowStep step,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent,
MeetingWorkflowExecutionContext context,
MeetingWorkflowTemplateModel model,
CancellationToken cancellationToken)
{
@@ -232,14 +283,17 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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,
context.Event.Artifacts,
value,
cancellationToken);
return false;
case "set_property" when IsTranscriptLineProperty(step.Property ?? step.Name):
SetTranscriptLine(context, value);
return false;
case "set_property":
return SetProperty(meeting, step.Property ?? step.Name, value);
default:
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
}
@@ -272,6 +326,28 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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 string.Equals(property?.Trim(), "transcript.line", StringComparison.OrdinalIgnoreCase);
}
private static bool AddUnique(
List<string> values,
string value,
@@ -338,8 +414,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private static IReadOnlyDictionary<string, object?> BuildParameters(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var workflowEvent = context.Event;
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["meeting.attendees.count"] = meeting.Frontmatter.Attendees.Count,
@@ -350,14 +427,17 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
["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
["speaker.name"] = workflowEvent.SpeakerName,
["transcript.line"] = context.TranscriptLine ?? "",
["transcript.speaker"] = workflowEvent.SpeakerName ?? ""
};
}
private static MeetingWorkflowTemplateModel CreateTemplateModel(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var workflowEvent = context.Event;
return new MeetingWorkflowTemplateModel(
new MeetingWorkflowMeetingModel(
meeting.Frontmatter.Title,
@@ -370,6 +450,22 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : null),
string.IsNullOrWhiteSpace(workflowEvent.SpeakerName)
? null
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName));
: 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; }
}
}
@@ -6,7 +6,8 @@ public enum MeetingWorkflowEventType
{
Created,
StateTransition,
SpeakerIdentified
SpeakerIdentified,
TranscriptLine
}
public sealed record MeetingWorkflowEvent(
@@ -14,7 +15,8 @@ public sealed record MeetingWorkflowEvent(
MeetingSessionArtifacts Artifacts,
AssistantContextState? FromState = null,
AssistantContextState? ToState = null,
string? SpeakerName = null)
string? SpeakerName = null,
string? TranscriptLineText = null)
{
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
{
@@ -35,4 +37,16 @@ public sealed record MeetingWorkflowEvent(
{
return new MeetingWorkflowEvent(MeetingWorkflowEventType.SpeakerIdentified, artifacts, SpeakerName: speakerName);
}
public static MeetingWorkflowEvent TranscriptLine(
MeetingSessionArtifacts artifacts,
string line,
string speakerName)
{
return new MeetingWorkflowEvent(
MeetingWorkflowEventType.TranscriptLine,
artifacts,
SpeakerName: speakerName,
TranscriptLineText: line);
}
}
@@ -34,6 +34,9 @@ public sealed class MeetingWorkflowTrigger
[YamlMember(Alias = "speaker_identified")]
public MeetingWorkflowSpeakerIdentifiedTrigger? SpeakerIdentified { get; set; }
[YamlMember(Alias = "transcript_line")]
public MeetingWorkflowTranscriptLineTrigger? TranscriptLine { get; set; }
}
public sealed class MeetingWorkflowStateTransitionTrigger
@@ -51,6 +54,12 @@ public sealed class MeetingWorkflowSpeakerIdentifiedTrigger
public string? Name { get; set; }
}
public sealed class MeetingWorkflowTranscriptLineTrigger
{
[YamlMember(Alias = "speaker")]
public string? Speaker { get; set; }
}
public sealed class MeetingWorkflowCondition
{
[YamlMember(Alias = "condition")]
@@ -84,7 +93,8 @@ public sealed class MeetingWorkflowStep
public sealed record MeetingWorkflowTemplateModel(
MeetingWorkflowMeetingModel Meeting,
MeetingWorkflowEventModel Event,
MeetingWorkflowSpeakerModel? Speaker);
MeetingWorkflowSpeakerModel? Speaker,
MeetingWorkflowTranscriptModel? Transcript);
public sealed record MeetingWorkflowMeetingModel(
string Title,
@@ -99,6 +109,8 @@ public sealed record MeetingWorkflowEventModel(
public sealed record MeetingWorkflowSpeakerModel(string Name);
public sealed record MeetingWorkflowTranscriptModel(string Line, string Speaker);
internal static class MeetingWorkflowStateNames
{
public static string ToRuleName(AssistantContextState state)
@@ -4,11 +4,11 @@ namespace MeetingAssistant.Workflow;
internal static class MeetingWorkflowRulesValidator
{
private static readonly string[] SupportedTriggerKeys = ["created", "state_transition", "speaker_identified"];
private static readonly string[] SupportedTriggerKeys = ["created", "state_transition", "speaker_identified", "transcript_line"];
private static readonly string[] SupportedConditionKeys = ["condition", "and", "or", "not"];
private static readonly string[] SupportedStepUses =
["add_attendee", "remove_attendee", "add_project", "set_property", "add_context"];
private static readonly string[] SupportedSetPropertyNames = ["title", "meeting.title"];
private static readonly string[] SupportedSetPropertyNames = ["title", "meeting.title", "transcript.line"];
public static async Task<IReadOnlyList<string>> ValidateAsync(MeetingWorkflowRulesFile rulesFile)
{
@@ -43,6 +43,7 @@ internal static class MeetingWorkflowRulesValidator
{
await ValidateStepAsync(
rule.Steps[stepIndex],
rule,
ruleName,
stepIndex,
errors,
@@ -63,7 +64,8 @@ internal static class MeetingWorkflowRulesValidator
var configuredCount = CountConfigured(
trigger.Created is not null,
trigger.StateTransition is not null,
trigger.SpeakerIdentified is not null);
trigger.SpeakerIdentified is not null,
trigger.TranscriptLine is not null);
if (configuredCount == 0)
{
errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(SupportedTriggerKeys)}.");
@@ -120,6 +122,7 @@ internal static class MeetingWorkflowRulesValidator
private static async Task ValidateStepAsync(
MeetingWorkflowStep step,
MeetingWorkflowRule rule,
string ruleName,
int stepIndex,
List<string> errors,
@@ -151,6 +154,11 @@ internal static class MeetingWorkflowRulesValidator
{
errors.Add($"{stepPath} uses unsupported property '{property}'. Supported properties: {JoinAllowed(SupportedSetPropertyNames)}.");
}
else if (string.Equals(property.Trim(), "transcript.line", StringComparison.OrdinalIgnoreCase) &&
!HasTranscriptLineTrigger(rule))
{
errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger.");
}
}
if (step.Value is { } value &&
@@ -174,6 +182,11 @@ internal static class MeetingWorkflowRulesValidator
: $"rule '{rule.Name}'";
}
private static bool HasTranscriptLineTrigger(MeetingWorkflowRule rule)
{
return rule.On.Any(static trigger => trigger.TranscriptLine is not null);
}
private static int CountConfigured(params bool[] values)
{
return values.Count(static value => value);
@@ -196,7 +209,10 @@ internal static class MeetingWorkflowRulesValidator
MeetingWorkflowEventType.StateTransition.ToString(),
MeetingWorkflowStateNames.ToRuleName(AssistantContextState.CollectingMetadata),
MeetingWorkflowStateNames.ToRuleName(AssistantContextState.Transcribing)),
new MeetingWorkflowSpeakerModel("Ada Lovelace"));
new MeetingWorkflowSpeakerModel("Ada Lovelace"),
new MeetingWorkflowTranscriptModel(
"[00:00:01] Ada Lovelace: Validation line.",
"Ada Lovelace"));
}
private static string FormatRazorValidationMessage(Exception exception)