Public Access
Fix workflow rule validation logging
PR and Push Build/Test / build-and-test (push) Failing after 9m19s
PR and Push Build/Test / build-and-test (push) Failing after 9m19s
This commit is contained in:
@@ -10,7 +10,6 @@ internal static class MeetingWorkflowTestRules
|
|||||||
- transcript_line: {}
|
- transcript_line: {}
|
||||||
if:
|
if:
|
||||||
- condition: contains(transcript.line, '*****')
|
- condition: contains(transcript.line, '*****')
|
||||||
- condition: transcript.speaker = 'Guest-1'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: set_property
|
- uses: set_property
|
||||||
property: transcript.line
|
property: transcript.line
|
||||||
|
|||||||
@@ -538,34 +538,20 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
|
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("rules:\n- name: [", replace_file: true);
|
var result = await fixture.Tools.WriteRules("rules:\n- name: [", replace_file: true);
|
||||||
|
|
||||||
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
|
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
|
||||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsRefuseWorkflowRulesThatWouldFailAtRuntime()
|
public async Task RulesEditorToolsRefuseWorkflowRulesThatWouldFailAtRuntime()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("""
|
var result = await fixture.Tools.WriteRules("""
|
||||||
rules:
|
rules:
|
||||||
- name: broken-step
|
- name: broken-step
|
||||||
on:
|
on:
|
||||||
@@ -579,22 +565,37 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Assert.Contains("broken-step", result);
|
Assert.Contains("broken-step", result);
|
||||||
Assert.Contains("send_email", result);
|
Assert.Contains("send_email", result);
|
||||||
Assert.Contains("add_attendee", result);
|
Assert.Contains("add_attendee", result);
|
||||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RulesEditorToolsLogWorkflowRuleValidationFailures()
|
||||||
|
{
|
||||||
|
var logger = new CapturingLogger();
|
||||||
|
var fixture = await CreateRulesEditorFixtureAsync(logger);
|
||||||
|
|
||||||
|
var result = await fixture.Tools.WriteRules("""
|
||||||
|
rules:
|
||||||
|
- name: broken-step
|
||||||
|
on:
|
||||||
|
- created: {}
|
||||||
|
steps:
|
||||||
|
- uses: send_email
|
||||||
|
value: Manuel
|
||||||
|
""", replace_file: true);
|
||||||
|
|
||||||
|
Assert.StartsWith("Refused: workflow rules are invalid.", result);
|
||||||
|
Assert.Contains(logger.Messages, message =>
|
||||||
|
message.Contains(nameof(WorkflowRulesEditorTools.WriteRules), StringComparison.Ordinal) &&
|
||||||
|
message.Contains("send_email", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsExplainUnsupportedWorkflowProperties()
|
public async Task RulesEditorToolsExplainUnsupportedWorkflowProperties()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("""
|
var result = await fixture.Tools.WriteRules("""
|
||||||
rules:
|
rules:
|
||||||
- name: broken-property
|
- name: broken-property
|
||||||
on:
|
on:
|
||||||
@@ -609,22 +610,15 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Assert.Contains("broken-property", result);
|
Assert.Contains("broken-property", result);
|
||||||
Assert.Contains("meeting.status", result);
|
Assert.Contains("meeting.status", result);
|
||||||
Assert.Contains("title", result);
|
Assert.Contains("title", result);
|
||||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsRefuseTranscriptLinePropertyOutsideTranscriptLineTrigger()
|
public async Task RulesEditorToolsRefuseTranscriptLinePropertyOutsideTranscriptLineTrigger()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("""
|
var result = await fixture.Tools.WriteRules("""
|
||||||
rules:
|
rules:
|
||||||
- name: broken-transcript-property
|
- name: broken-transcript-property
|
||||||
on:
|
on:
|
||||||
@@ -639,22 +633,37 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Assert.Contains("broken-transcript-property", result);
|
Assert.Contains("broken-transcript-property", result);
|
||||||
Assert.Contains("transcript.line", result);
|
Assert.Contains("transcript.line", result);
|
||||||
Assert.Contains("transcript_line", result);
|
Assert.Contains("transcript_line", result);
|
||||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RulesEditorToolsAcceptAndParseMaskedProfanityRedactionRule()
|
||||||
|
{
|
||||||
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
|
|
||||||
|
var result = await fixture.Tools.WriteRules(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml, replace_file: true);
|
||||||
|
|
||||||
|
Assert.Equal(fixture.RulesPath, result);
|
||||||
|
var provider = new FileMeetingWorkflowRulesProvider(
|
||||||
|
NullLogger<FileMeetingWorkflowRulesProvider>.Instance);
|
||||||
|
var rule = Assert.Single(await provider.GetRulesAsync(fixture.Options, CancellationToken.None));
|
||||||
|
Assert.Equal("redact-masked-profanity", rule.Name);
|
||||||
|
Assert.NotNull(Assert.Single(rule.On).TranscriptLine);
|
||||||
|
Assert.Equal("contains(transcript.line, '*****')", Assert.Single(rule.If).Condition);
|
||||||
|
var step = Assert.Single(rule.Steps);
|
||||||
|
Assert.Equal("set_property", step.Uses);
|
||||||
|
Assert.Equal("transcript.line", step.Property);
|
||||||
|
Assert.Equal(
|
||||||
|
"""@Model.Transcript.Line.Replace("*****", "[redacted]")""",
|
||||||
|
step.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsRefuseMalformedRazorStepValues()
|
public async Task RulesEditorToolsRefuseMalformedRazorStepValues()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync();
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("""
|
var result = await fixture.Tools.WriteRules("""
|
||||||
rules:
|
rules:
|
||||||
- name: broken-razor
|
- name: broken-razor
|
||||||
on:
|
on:
|
||||||
@@ -668,21 +677,15 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
Assert.Contains("broken-razor", result);
|
Assert.Contains("broken-razor", result);
|
||||||
Assert.Contains("Razor", result);
|
Assert.Contains("Razor", result);
|
||||||
Assert.Contains("ThisIsNotAModelVar", result);
|
Assert.Contains("ThisIsNotAModelVar", result);
|
||||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RulesEditorToolsAllowValidRazorAndEmailStepValues()
|
public async Task RulesEditorToolsAllowValidRazorAndEmailStepValues()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
var fixture = await CreateRulesEditorFixtureAsync(initialContent: "");
|
||||||
Directory.CreateDirectory(root);
|
|
||||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
|
||||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
|
||||||
{
|
|
||||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
|
||||||
});
|
|
||||||
|
|
||||||
var result = await tools.WriteRules("""
|
var result = await fixture.Tools.WriteRules("""
|
||||||
rules:
|
rules:
|
||||||
- name: valid-razor
|
- name: valid-razor
|
||||||
on:
|
on:
|
||||||
@@ -692,9 +695,9 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
value: "Speaker @Model.Speaker.Name can contact Support@example.com"
|
value: "Speaker @Model.Speaker.Name can contact Support@example.com"
|
||||||
""", replace_file: true);
|
""", replace_file: true);
|
||||||
|
|
||||||
Assert.Equal(rulesPath, result);
|
Assert.Equal(fixture.RulesPath, result);
|
||||||
Assert.Contains("@Model.Speaker.Name", await File.ReadAllTextAsync(rulesPath));
|
Assert.Contains("@Model.Speaker.Name", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
Assert.Contains("Support@example.com", await File.ReadAllTextAsync(rulesPath));
|
Assert.Contains("Support@example.com", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1159,6 +1162,55 @@ public sealed class WorkflowRulesEditorTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class CapturingLogger : ILogger
|
||||||
|
{
|
||||||
|
public List<string> Messages { get; } = [];
|
||||||
|
|
||||||
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
|
where TState : notnull
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled(LogLevel logLevel)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log<TState>(
|
||||||
|
LogLevel logLevel,
|
||||||
|
EventId eventId,
|
||||||
|
TState state,
|
||||||
|
Exception? exception,
|
||||||
|
Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
Messages.Add(formatter(state, exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<RulesEditorFixture> CreateRulesEditorFixtureAsync(
|
||||||
|
ILogger? logger = null,
|
||||||
|
string initialContent = "rules: []")
|
||||||
|
{
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(root);
|
||||||
|
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||||
|
await File.WriteAllTextAsync(rulesPath, initialContent);
|
||||||
|
var options = new MeetingAssistantOptions
|
||||||
|
{
|
||||||
|
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||||
|
};
|
||||||
|
return new RulesEditorFixture(
|
||||||
|
rulesPath,
|
||||||
|
options,
|
||||||
|
new WorkflowRulesEditorTools(options, logger: logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RulesEditorFixture(
|
||||||
|
string RulesPath,
|
||||||
|
MeetingAssistantOptions Options,
|
||||||
|
WorkflowRulesEditorTools Tools);
|
||||||
|
|
||||||
private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable
|
private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly string tempDirectory;
|
private readonly string tempDirectory;
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
|||||||
"state.from",
|
"state.from",
|
||||||
"state.to",
|
"state.to",
|
||||||
"speaker.name",
|
"speaker.name",
|
||||||
"transcript.line",
|
MeetingWorkflowRuleSchema.PropertyTranscriptLine,
|
||||||
"transcript.speaker"
|
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly IMeetingWorkflowRulesProvider rulesProvider;
|
private readonly IMeetingWorkflowRulesProvider rulesProvider;
|
||||||
@@ -277,22 +277,22 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
|||||||
var value = await RenderValueAsync(step.Value ?? "", model);
|
var value = await RenderValueAsync(step.Value ?? "", model);
|
||||||
switch (step.Uses.Trim().ToLowerInvariant())
|
switch (step.Uses.Trim().ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "add_attendee":
|
case MeetingWorkflowRuleSchema.StepAddAttendee:
|
||||||
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
|
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
|
||||||
case "remove_attendee":
|
case MeetingWorkflowRuleSchema.StepRemoveAttendee:
|
||||||
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
||||||
case "add_project":
|
case MeetingWorkflowRuleSchema.StepAddProject:
|
||||||
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
|
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
|
||||||
case "add_context":
|
case MeetingWorkflowRuleSchema.StepAddContext:
|
||||||
await meetingArtifactStore.AppendAssistantContextAsync(
|
await meetingArtifactStore.AppendAssistantContextAsync(
|
||||||
context.Event.Artifacts,
|
context.Event.Artifacts,
|
||||||
value,
|
value,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return false;
|
return false;
|
||||||
case "set_property" when IsTranscriptLineProperty(step.Property ?? step.Name):
|
case MeetingWorkflowRuleSchema.StepSetProperty when IsTranscriptLineProperty(step.Property ?? step.Name):
|
||||||
SetTranscriptLine(context, value);
|
SetTranscriptLine(context, value);
|
||||||
return false;
|
return false;
|
||||||
case "set_property":
|
case MeetingWorkflowRuleSchema.StepSetProperty:
|
||||||
return SetProperty(meeting, step.Property ?? step.Name, value);
|
return SetProperty(meeting, step.Property ?? step.Name, value);
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
|
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
|
||||||
@@ -312,7 +312,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
|||||||
string value)
|
string value)
|
||||||
{
|
{
|
||||||
var normalized = property?.Trim().ToLowerInvariant();
|
var normalized = property?.Trim().ToLowerInvariant();
|
||||||
if (normalized is "title" or "meeting.title")
|
if (normalized is MeetingWorkflowRuleSchema.PropertyTitle or MeetingWorkflowRuleSchema.PropertyMeetingTitle)
|
||||||
{
|
{
|
||||||
if (string.Equals(meeting.Frontmatter.Title, value, StringComparison.Ordinal))
|
if (string.Equals(meeting.Frontmatter.Title, value, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
@@ -345,7 +345,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
|||||||
|
|
||||||
private static bool IsTranscriptLineProperty(string? property)
|
private static bool IsTranscriptLineProperty(string? property)
|
||||||
{
|
{
|
||||||
return string.Equals(property?.Trim(), "transcript.line", StringComparison.OrdinalIgnoreCase);
|
return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AddUnique(
|
private static bool AddUnique(
|
||||||
@@ -428,8 +428,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
|||||||
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
|
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
|
||||||
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
|
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
|
||||||
["speaker.name"] = workflowEvent.SpeakerName,
|
["speaker.name"] = workflowEvent.SpeakerName,
|
||||||
["transcript.line"] = context.TranscriptLine ?? "",
|
[MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "",
|
||||||
["transcript.speaker"] = workflowEvent.SpeakerName ?? ""
|
[MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using RazorLight;
|
||||||
|
|
||||||
|
namespace MeetingAssistant.Workflow;
|
||||||
|
|
||||||
|
internal static class MeetingWorkflowRazorEngineFactory
|
||||||
|
{
|
||||||
|
public static RazorLightEngine Create()
|
||||||
|
{
|
||||||
|
return new RazorLightEngineBuilder()
|
||||||
|
.UseNoProject()
|
||||||
|
.SetOperatingAssembly(typeof(RazorLightEngine).Assembly)
|
||||||
|
.AddMetadataReferences(CreateMetadataReferences())
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MetadataReference[] CreateMetadataReferences()
|
||||||
|
{
|
||||||
|
return AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location))
|
||||||
|
.Select(assembly => assembly.Location)
|
||||||
|
.Where(File.Exists)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(path => MetadataReference.CreateFromFile(path))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
namespace MeetingAssistant.Workflow;
|
||||||
|
|
||||||
|
internal static class MeetingWorkflowRuleSchema
|
||||||
|
{
|
||||||
|
public const string StepAddAttendee = "add_attendee";
|
||||||
|
public const string StepRemoveAttendee = "remove_attendee";
|
||||||
|
public const string StepAddProject = "add_project";
|
||||||
|
public const string StepSetProperty = "set_property";
|
||||||
|
public const string StepAddContext = "add_context";
|
||||||
|
|
||||||
|
public const string PropertyTitle = "title";
|
||||||
|
public const string PropertyMeetingTitle = "meeting.title";
|
||||||
|
public const string PropertyTranscriptLine = "transcript.line";
|
||||||
|
public const string ParameterTranscriptSpeaker = "transcript.speaker";
|
||||||
|
|
||||||
|
public static readonly string[] SupportedTriggerKeys =
|
||||||
|
[
|
||||||
|
"created",
|
||||||
|
"state_transition",
|
||||||
|
"speaker_identified",
|
||||||
|
"transcript_line"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SupportedConditionKeys =
|
||||||
|
[
|
||||||
|
"condition",
|
||||||
|
"and",
|
||||||
|
"or",
|
||||||
|
"not"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SupportedStepUses =
|
||||||
|
[
|
||||||
|
StepAddAttendee,
|
||||||
|
StepRemoveAttendee,
|
||||||
|
StepAddProject,
|
||||||
|
StepSetProperty,
|
||||||
|
StepAddContext
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] SupportedSetPropertyNames =
|
||||||
|
[
|
||||||
|
PropertyTitle,
|
||||||
|
PropertyMeetingTitle,
|
||||||
|
PropertyTranscriptLine
|
||||||
|
];
|
||||||
|
|
||||||
|
public static bool IsStep(string? value, string step)
|
||||||
|
{
|
||||||
|
return string.Equals(value?.Trim(), step, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsProperty(string? value, string property)
|
||||||
|
{
|
||||||
|
return string.Equals(value?.Trim(), property, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,6 @@ namespace MeetingAssistant.Workflow;
|
|||||||
|
|
||||||
internal static class MeetingWorkflowRulesValidator
|
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<IReadOnlyList<string>> ValidateAsync(MeetingWorkflowRulesFile rulesFile)
|
public static async Task<IReadOnlyList<string>> ValidateAsync(MeetingWorkflowRulesFile rulesFile)
|
||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
@@ -21,7 +15,7 @@ internal static class MeetingWorkflowRulesValidator
|
|||||||
var ruleName = GetRuleName(rule, ruleIndex);
|
var ruleName = GetRuleName(rule, ruleIndex);
|
||||||
if (rule.On.Count == 0)
|
if (rule.On.Count == 0)
|
||||||
{
|
{
|
||||||
errors.Add($"{ruleName} must define at least one trigger in 'on'. Supported triggers: {JoinAllowed(SupportedTriggerKeys)}.");
|
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++)
|
for (var triggerIndex = 0; triggerIndex < rule.On.Count; triggerIndex++)
|
||||||
@@ -36,7 +30,7 @@ internal static class MeetingWorkflowRulesValidator
|
|||||||
|
|
||||||
if (rule.Steps.Count == 0)
|
if (rule.Steps.Count == 0)
|
||||||
{
|
{
|
||||||
errors.Add($"{ruleName} must define at least one step. Supported steps: {JoinAllowed(SupportedStepUses)}.");
|
errors.Add($"{ruleName} must define at least one step. Supported steps: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedStepUses)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var stepIndex = 0; stepIndex < rule.Steps.Count; stepIndex++)
|
for (var stepIndex = 0; stepIndex < rule.Steps.Count; stepIndex++)
|
||||||
@@ -68,7 +62,7 @@ internal static class MeetingWorkflowRulesValidator
|
|||||||
trigger.TranscriptLine is not null);
|
trigger.TranscriptLine is not null);
|
||||||
if (configuredCount == 0)
|
if (configuredCount == 0)
|
||||||
{
|
{
|
||||||
errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(SupportedTriggerKeys)}.");
|
errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedTriggerKeys)}.");
|
||||||
}
|
}
|
||||||
else if (configuredCount > 1)
|
else if (configuredCount > 1)
|
||||||
{
|
{
|
||||||
@@ -89,7 +83,7 @@ internal static class MeetingWorkflowRulesValidator
|
|||||||
condition.Not is not null);
|
condition.Not is not null);
|
||||||
if (configuredCount == 0)
|
if (configuredCount == 0)
|
||||||
{
|
{
|
||||||
errors.Add($"{ruleName} {path} must contain one supported condition key: {JoinAllowed(SupportedConditionKeys)}.");
|
errors.Add($"{ruleName} {path} must contain one supported condition key: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedConditionKeys)}.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,28 +127,28 @@ internal static class MeetingWorkflowRulesValidator
|
|||||||
var uses = step.Uses.Trim();
|
var uses = step.Uses.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(uses))
|
if (string.IsNullOrWhiteSpace(uses))
|
||||||
{
|
{
|
||||||
errors.Add($"{stepPath} must define 'uses'. Supported steps: {JoinAllowed(SupportedStepUses)}.");
|
errors.Add($"{stepPath} must define 'uses'. Supported steps: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedStepUses)}.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!SupportedStepUses.Contains(uses, StringComparer.OrdinalIgnoreCase))
|
if (!MeetingWorkflowRuleSchema.SupportedStepUses.Contains(uses, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
errors.Add($"{stepPath} uses unsupported step '{uses}'. Supported steps: {JoinAllowed(SupportedStepUses)}.");
|
errors.Add($"{stepPath} uses unsupported step '{uses}'. Supported steps: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedStepUses)}.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(uses, "set_property", StringComparison.OrdinalIgnoreCase))
|
if (MeetingWorkflowRuleSchema.IsStep(uses, MeetingWorkflowRuleSchema.StepSetProperty))
|
||||||
{
|
{
|
||||||
var property = step.Property ?? step.Name;
|
var property = step.Property ?? step.Name;
|
||||||
if (string.IsNullOrWhiteSpace(property))
|
if (string.IsNullOrWhiteSpace(property))
|
||||||
{
|
{
|
||||||
errors.Add($"{stepPath} must define 'property' or 'name' for set_property. Supported properties: {JoinAllowed(SupportedSetPropertyNames)}.");
|
errors.Add($"{stepPath} must define 'property' or 'name' for set_property. Supported properties: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedSetPropertyNames)}.");
|
||||||
}
|
}
|
||||||
else if (!SupportedSetPropertyNames.Contains(property.Trim(), StringComparer.OrdinalIgnoreCase))
|
else if (!MeetingWorkflowRuleSchema.SupportedSetPropertyNames.Contains(property.Trim(), StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
errors.Add($"{stepPath} uses unsupported property '{property}'. Supported properties: {JoinAllowed(SupportedSetPropertyNames)}.");
|
errors.Add($"{stepPath} uses unsupported property '{property}'. Supported properties: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedSetPropertyNames)}.");
|
||||||
}
|
}
|
||||||
else if (string.Equals(property.Trim(), "transcript.line", StringComparison.OrdinalIgnoreCase) &&
|
else if (MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine) &&
|
||||||
!HasTranscriptLineTrigger(rule))
|
!HasTranscriptLineTrigger(rule))
|
||||||
{
|
{
|
||||||
errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger.");
|
errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger.");
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ internal sealed class MeetingWorkflowTemplateRenderer
|
|||||||
@"(?<![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-])",
|
@"(?<![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);
|
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
|
private readonly RazorLightEngine razorEngine = MeetingWorkflowRazorEngineFactory.Create();
|
||||||
.UseNoProject()
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
public async Task<string> RenderAsync(
|
public async Task<string> RenderAsync(
|
||||||
string template,
|
string template,
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
|||||||
launchProfiles,
|
launchProfiles,
|
||||||
identityMergeService,
|
identityMergeService,
|
||||||
asrDiagnosticService,
|
asrDiagnosticService,
|
||||||
configuration);
|
configuration,
|
||||||
|
logger: logger);
|
||||||
var messages = conversation
|
var messages = conversation
|
||||||
.Select(ToChatMessage)
|
.Select(ToChatMessage)
|
||||||
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using MeetingAssistant.Speakers;
|
|||||||
using MeetingAssistant.Transcription;
|
using MeetingAssistant.Transcription;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using YamlDotNet.Core;
|
using YamlDotNet.Core;
|
||||||
using YamlDotNet.Serialization;
|
using YamlDotNet.Serialization;
|
||||||
using YamlDotNet.Serialization.NamingConventions;
|
using YamlDotNet.Serialization.NamingConventions;
|
||||||
@@ -47,6 +48,7 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
private readonly ISpeakerIdentityMergeService? identityMergeService;
|
private readonly ISpeakerIdentityMergeService? identityMergeService;
|
||||||
private readonly AsrDiagnosticService? asrDiagnosticService;
|
private readonly AsrDiagnosticService? asrDiagnosticService;
|
||||||
private readonly IConfiguration? configuration;
|
private readonly IConfiguration? configuration;
|
||||||
|
private readonly ILogger? logger;
|
||||||
|
|
||||||
public WorkflowRulesEditorTools(
|
public WorkflowRulesEditorTools(
|
||||||
MeetingAssistantOptions options,
|
MeetingAssistantOptions options,
|
||||||
@@ -62,7 +64,8 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
string? configDocsPath = null,
|
string? configDocsPath = null,
|
||||||
string? logDirectory = null,
|
string? logDirectory = null,
|
||||||
string? specRootPath = null,
|
string? specRootPath = null,
|
||||||
string? projectAgentsTemplatePath = null)
|
string? projectAgentsTemplatePath = null,
|
||||||
|
ILogger? logger = null)
|
||||||
{
|
{
|
||||||
this.options = options;
|
this.options = options;
|
||||||
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||||
@@ -85,6 +88,7 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
this.identityMergeService = identityMergeService;
|
this.identityMergeService = identityMergeService;
|
||||||
this.asrDiagnosticService = asrDiagnosticService;
|
this.asrDiagnosticService = asrDiagnosticService;
|
||||||
this.configuration = configuration;
|
this.configuration = configuration;
|
||||||
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
|
public async Task<string> ReadRules(int? from = null, int? to = null, int? tail = null)
|
||||||
@@ -106,12 +110,12 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
{
|
{
|
||||||
if (rulesPath is null)
|
if (rulesPath is null)
|
||||||
{
|
{
|
||||||
return "Refused: workflow rules file is not configured.";
|
return LogRefusal(nameof(WriteRules), "Refused: workflow rules file is not configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (yaml is null)
|
if (yaml is null)
|
||||||
{
|
{
|
||||||
return "Refused: yaml must not be null.";
|
return LogRefusal(nameof(WriteRules), "Refused: yaml must not be null.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = File.Exists(rulesPath)
|
var existing = File.Exists(rulesPath)
|
||||||
@@ -123,7 +127,7 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
var validation = await ValidateYamlAsync(updated);
|
var validation = await ValidateYamlAsync(updated);
|
||||||
if (validation is not null)
|
if (validation is not null)
|
||||||
{
|
{
|
||||||
return validation;
|
return LogRefusal(nameof(WriteRules), validation);
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
|
||||||
@@ -163,13 +167,13 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
{
|
{
|
||||||
if (json is null)
|
if (json is null)
|
||||||
{
|
{
|
||||||
return "Refused: json must not be null.";
|
return LogRefusal(nameof(WriteConfig), "Refused: json must not be null.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var validation = ValidateJson(json);
|
var validation = ValidateJson(json);
|
||||||
if (validation is not null)
|
if (validation is not null)
|
||||||
{
|
{
|
||||||
return validation;
|
return LogRefusal(nameof(WriteConfig), validation);
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
|
||||||
@@ -177,6 +181,15 @@ public sealed class WorkflowRulesEditorTools
|
|||||||
return configPath;
|
return configPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string LogRefusal(string toolName, string refusal)
|
||||||
|
{
|
||||||
|
logger?.LogWarning(
|
||||||
|
"Workflow rules editor tool {ToolName} refused request: {Refusal}",
|
||||||
|
toolName,
|
||||||
|
refusal);
|
||||||
|
return refusal;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> ReadConfigDocs(int? from = null, int? to = null, int? tail = null)
|
public async Task<string> ReadConfigDocs(int? from = null, int? to = null, int? tail = null)
|
||||||
{
|
{
|
||||||
if (!File.Exists(configDocsPath))
|
if (!File.Exists(configDocsPath))
|
||||||
|
|||||||
Reference in New Issue
Block a user