using MeetingAssistant.MeetingNotes; namespace MeetingAssistant.Workflow; internal static class MeetingWorkflowRulesValidator { 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", "transcript.line"]; public static async Task> ValidateAsync(MeetingWorkflowRulesFile rulesFile) { var errors = new List(); var templateRenderer = new MeetingWorkflowTemplateRenderer(); var templateModel = CreateValidationTemplateModel(); for (var ruleIndex = 0; ruleIndex < rulesFile.Rules.Count; ruleIndex++) { var rule = rulesFile.Rules[ruleIndex]; var ruleName = GetRuleName(rule, ruleIndex); if (rule.On.Count == 0) { errors.Add($"{ruleName} must define at least one trigger in 'on'. Supported triggers: {JoinAllowed(SupportedTriggerKeys)}."); } for (var triggerIndex = 0; triggerIndex < rule.On.Count; triggerIndex++) { ValidateTrigger(rule.On[triggerIndex], ruleName, triggerIndex, errors); } for (var conditionIndex = 0; conditionIndex < rule.If.Count; conditionIndex++) { ValidateCondition(rule.If[conditionIndex], ruleName, $"if[{conditionIndex + 1}]", errors); } if (rule.Steps.Count == 0) { errors.Add($"{ruleName} must define at least one step. Supported steps: {JoinAllowed(SupportedStepUses)}."); } for (var stepIndex = 0; stepIndex < rule.Steps.Count; stepIndex++) { await ValidateStepAsync( rule.Steps[stepIndex], rule, ruleName, stepIndex, errors, templateRenderer, templateModel); } } return errors; } private static void ValidateTrigger( MeetingWorkflowTrigger trigger, string ruleName, int triggerIndex, List errors) { var configuredCount = CountConfigured( trigger.Created is not null, trigger.StateTransition 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)}."); } else if (configuredCount > 1) { errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain only one trigger."); } } private static void ValidateCondition( MeetingWorkflowCondition condition, string ruleName, string path, List errors) { var configuredCount = CountConfigured( !string.IsNullOrWhiteSpace(condition.Condition), condition.And is { Count: > 0 }, condition.Or is { Count: > 0 }, condition.Not is not null); if (configuredCount == 0) { errors.Add($"{ruleName} {path} must contain one supported condition key: {JoinAllowed(SupportedConditionKeys)}."); return; } if (configuredCount > 1) { errors.Add($"{ruleName} {path} must contain only one condition key."); } if (condition.And is { Count: > 0 } andConditions) { for (var index = 0; index < andConditions.Count; index++) { ValidateCondition(andConditions[index], ruleName, $"{path}.and[{index + 1}]", errors); } } if (condition.Or is { Count: > 0 } orConditions) { for (var index = 0; index < orConditions.Count; index++) { ValidateCondition(orConditions[index], ruleName, $"{path}.or[{index + 1}]", errors); } } if (condition.Not is not null) { ValidateCondition(condition.Not, ruleName, $"{path}.not", errors); } } private static async Task ValidateStepAsync( MeetingWorkflowStep step, MeetingWorkflowRule rule, string ruleName, int stepIndex, List errors, MeetingWorkflowTemplateRenderer templateRenderer, MeetingWorkflowTemplateModel templateModel) { var stepPath = $"{ruleName} steps[{stepIndex + 1}]"; var uses = step.Uses.Trim(); if (string.IsNullOrWhiteSpace(uses)) { errors.Add($"{stepPath} must define 'uses'. Supported steps: {JoinAllowed(SupportedStepUses)}."); return; } if (!SupportedStepUses.Contains(uses, StringComparer.OrdinalIgnoreCase)) { errors.Add($"{stepPath} uses unsupported step '{uses}'. Supported steps: {JoinAllowed(SupportedStepUses)}."); return; } if (string.Equals(uses, "set_property", StringComparison.OrdinalIgnoreCase)) { var property = step.Property ?? step.Name; if (string.IsNullOrWhiteSpace(property)) { errors.Add($"{stepPath} must define 'property' or 'name' for set_property. Supported properties: {JoinAllowed(SupportedSetPropertyNames)}."); } else if (!SupportedSetPropertyNames.Contains(property.Trim(), StringComparer.OrdinalIgnoreCase)) { 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 && MeetingWorkflowTemplateRenderer.ContainsRazorTemplateSyntax(value)) { try { _ = await templateRenderer.RenderAsync(value, templateModel); } catch (Exception exception) { errors.Add($"{stepPath} has invalid Razor template in 'value' ({value}). {FormatRazorValidationMessage(exception)}"); } } } private static string GetRuleName(MeetingWorkflowRule rule, int ruleIndex) { return string.IsNullOrWhiteSpace(rule.Name) ? $"rule[{ruleIndex + 1}]" : $"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); } private static string JoinAllowed(IEnumerable values) { return string.Join(", ", values); } private static MeetingWorkflowTemplateModel CreateValidationTemplateModel() { return new MeetingWorkflowTemplateModel( new MeetingWorkflowMeetingModel( "Validation Meeting", ["Ada Lovelace"], ["Validation Project"], MeetingWorkflowStateNames.ToRuleName(AssistantContextState.Transcribing)), new MeetingWorkflowEventModel( MeetingWorkflowEventType.StateTransition.ToString(), MeetingWorkflowStateNames.ToRuleName(AssistantContextState.CollectingMetadata), MeetingWorkflowStateNames.ToRuleName(AssistantContextState.Transcribing)), new MeetingWorkflowSpeakerModel("Ada Lovelace"), new MeetingWorkflowTranscriptModel( "[00:00:01] Ada Lovelace: Validation line.", "Ada Lovelace")); } private static string FormatRazorValidationMessage(Exception exception) { var message = exception.Message.Split(Environment.NewLine)[0].Trim(); return string.IsNullOrWhiteSpace(message) ? "The template could not be compiled or rendered with the workflow model." : message; } }