using MeetingAssistant.MeetingNotes; namespace MeetingAssistant.Workflow; internal static class MeetingWorkflowRulesValidator { 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(MeetingWorkflowRuleSchema.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(MeetingWorkflowRuleSchema.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, trigger.AttendeeAdded is not null); if (configuredCount == 0) { errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(MeetingWorkflowRuleSchema.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(MeetingWorkflowRuleSchema.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(MeetingWorkflowRuleSchema.SupportedStepUses)}."); return; } if (!MeetingWorkflowRuleSchema.SupportedStepUses.Contains(uses, StringComparer.OrdinalIgnoreCase)) { errors.Add($"{stepPath} uses unsupported step '{uses}'. Supported steps: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedStepUses)}."); 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; if (string.IsNullOrWhiteSpace(property)) { errors.Add($"{stepPath} must define 'property' or 'name' for set_property. Supported properties: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedSetPropertyNames)}."); } else if (!MeetingWorkflowRuleSchema.SupportedSetPropertyNames.Contains(property.Trim(), StringComparer.OrdinalIgnoreCase)) { errors.Add($"{stepPath} uses unsupported property '{property}'. Supported properties: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedSetPropertyNames)}."); } else if (MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine) && !HasTranscriptLineTrigger(rule)) { 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 && 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 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); } 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"), new MeetingWorkflowAttendeeModel("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; } }