Public Access
Validate workflow rule writes
This commit is contained in:
@@ -3,7 +3,6 @@ using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using NCalc;
|
||||
using RazorLight;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
@@ -30,10 +29,6 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
|
||||
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private static readonly Regex EmailAddressPattern = new(
|
||||
@"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])(?<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)@(?<domain>(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})(?![A-Za-z0-9-])",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly string[] ParameterNames =
|
||||
[
|
||||
"meeting.attendees.count",
|
||||
@@ -51,9 +46,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
private readonly IMeetingArtifactStore meetingArtifactStore;
|
||||
private readonly ILogger<MeetingWorkflowEngine> logger;
|
||||
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
|
||||
.UseNoProject()
|
||||
.Build();
|
||||
private readonly MeetingWorkflowTemplateRenderer templateRenderer = new();
|
||||
|
||||
public MeetingWorkflowEngine(
|
||||
IMeetingWorkflowRulesProvider rulesProvider,
|
||||
@@ -256,63 +249,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
string template,
|
||||
MeetingWorkflowTemplateModel model)
|
||||
{
|
||||
if (!ContainsRazorTemplateSyntax(template))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
return await razorEngine.CompileRenderStringAsync(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
EscapeEmailAddressAtSigns(template),
|
||||
model);
|
||||
}
|
||||
|
||||
private static bool ContainsRazorTemplateSyntax(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var emailAtSigns = EmailAddressPattern
|
||||
.Matches(value)
|
||||
.Select(match => match.Index + match.Value.IndexOf('@', StringComparison.Ordinal))
|
||||
.ToHashSet();
|
||||
|
||||
for (var index = 0; index < value.Length; index++)
|
||||
{
|
||||
if (value[index] != '@')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < value.Length && value[index + 1] == '@')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (emailAtSigns.Contains(index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EscapeEmailAddressAtSigns(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return EmailAddressPattern.Replace(
|
||||
value,
|
||||
match => match.Value.Replace("@", "@@", StringComparison.Ordinal));
|
||||
return await templateRenderer.RenderAsync(template, model);
|
||||
}
|
||||
|
||||
private static bool SetProperty(
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class MeetingWorkflowRulesValidator
|
||||
{
|
||||
private static readonly string[] SupportedTriggerKeys = ["created", "state_transition", "speaker_identified"];
|
||||
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"];
|
||||
|
||||
public static async Task<IReadOnlyList<string>> ValidateAsync(MeetingWorkflowRulesFile rulesFile)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
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],
|
||||
ruleName,
|
||||
stepIndex,
|
||||
errors,
|
||||
templateRenderer,
|
||||
templateModel);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void ValidateTrigger(
|
||||
MeetingWorkflowTrigger trigger,
|
||||
string ruleName,
|
||||
int triggerIndex,
|
||||
List<string> errors)
|
||||
{
|
||||
var configuredCount = CountConfigured(
|
||||
trigger.Created is not null,
|
||||
trigger.StateTransition is not null,
|
||||
trigger.SpeakerIdentified 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<string> 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,
|
||||
string ruleName,
|
||||
int stepIndex,
|
||||
List<string> 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)}.");
|
||||
}
|
||||
}
|
||||
|
||||
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 int CountConfigured(params bool[] values)
|
||||
{
|
||||
return values.Count(static value => value);
|
||||
}
|
||||
|
||||
private static string JoinAllowed(IEnumerable<string> 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"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using RazorLight;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal sealed class MeetingWorkflowTemplateRenderer
|
||||
{
|
||||
private static readonly Regex EmailAddressPattern = new(
|
||||
@"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])(?<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)@(?<domain>(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})(?![A-Za-z0-9-])",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
|
||||
.UseNoProject()
|
||||
.Build();
|
||||
|
||||
public async Task<string> RenderAsync(
|
||||
string template,
|
||||
MeetingWorkflowTemplateModel model)
|
||||
{
|
||||
if (!ContainsRazorTemplateSyntax(template))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
return await razorEngine.CompileRenderStringAsync(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
EscapeEmailAddressAtSigns(template),
|
||||
model);
|
||||
}
|
||||
|
||||
public static bool ContainsRazorTemplateSyntax(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var emailAtSigns = EmailAddressPattern
|
||||
.Matches(value)
|
||||
.Select(match => match.Index + match.Value.IndexOf('@', StringComparison.Ordinal))
|
||||
.ToHashSet();
|
||||
|
||||
for (var index = 0; index < value.Length; index++)
|
||||
{
|
||||
if (value[index] != '@')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < value.Length && value[index + 1] == '@')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (emailAtSigns.Contains(index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EscapeEmailAddressAtSigns(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return EmailAddressPattern.Replace(
|
||||
value,
|
||||
match => match.Value.Replace("@", "@@", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ public sealed class WorkflowRulesEditorTools
|
||||
var updated = replace_file
|
||||
? yaml
|
||||
: AgentFileToolContent.AppendContent(existing, yaml);
|
||||
var validation = ValidateYaml(updated);
|
||||
var validation = await ValidateYamlAsync(updated);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
@@ -930,22 +930,27 @@ public sealed class WorkflowRulesEditorTools
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateYaml(string yaml)
|
||||
private static async Task<string?> ValidateYamlAsync(string yaml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(yaml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MeetingWorkflowRulesFile rulesFile;
|
||||
try
|
||||
{
|
||||
_ = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml);
|
||||
return null;
|
||||
rulesFile = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml) ?? new MeetingWorkflowRulesFile();
|
||||
}
|
||||
catch (YamlException exception)
|
||||
{
|
||||
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
|
||||
}
|
||||
|
||||
var errors = await MeetingWorkflowRulesValidator.ValidateAsync(rulesFile);
|
||||
return errors.Count == 0
|
||||
? null
|
||||
: "Refused: workflow rules are invalid. " + string.Join(" ", errors);
|
||||
}
|
||||
|
||||
private async Task WriteRecommendedProjectSeedAsync(string projectRoot, string projectName)
|
||||
|
||||
Reference in New Issue
Block a user