Fix workflow rule validation logging
PR and Push Build/Test / build-and-test (push) Failing after 9m19s

This commit is contained in:
2026-06-03 17:33:12 +02:00
parent 40853115c5
commit 351ed2eb50
9 changed files with 243 additions and 102 deletions
@@ -53,8 +53,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"state.from",
"state.to",
"speaker.name",
"transcript.line",
"transcript.speaker"
MeetingWorkflowRuleSchema.PropertyTranscriptLine,
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker
];
private readonly IMeetingWorkflowRulesProvider rulesProvider;
@@ -277,22 +277,22 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
var value = await RenderValueAsync(step.Value ?? "", model);
switch (step.Uses.Trim().ToLowerInvariant())
{
case "add_attendee":
case MeetingWorkflowRuleSchema.StepAddAttendee:
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
case "remove_attendee":
case MeetingWorkflowRuleSchema.StepRemoveAttendee:
return RemoveValue(meeting.Frontmatter.Attendees, value);
case "add_project":
case MeetingWorkflowRuleSchema.StepAddProject:
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
case "add_context":
case MeetingWorkflowRuleSchema.StepAddContext:
await meetingArtifactStore.AppendAssistantContextAsync(
context.Event.Artifacts,
value,
cancellationToken);
return false;
case "set_property" when IsTranscriptLineProperty(step.Property ?? step.Name):
case MeetingWorkflowRuleSchema.StepSetProperty when IsTranscriptLineProperty(step.Property ?? step.Name):
SetTranscriptLine(context, value);
return false;
case "set_property":
case MeetingWorkflowRuleSchema.StepSetProperty:
return SetProperty(meeting, step.Property ?? step.Name, value);
default:
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
@@ -312,7 +312,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
string value)
{
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))
{
@@ -345,7 +345,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
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(
@@ -428,8 +428,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
["speaker.name"] = workflowEvent.SpeakerName,
["transcript.line"] = context.TranscriptLine ?? "",
["transcript.speaker"] = workflowEvent.SpeakerName ?? ""
[MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "",
[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
{
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)
{
var errors = new List<string>();
@@ -21,7 +15,7 @@ internal static class MeetingWorkflowRulesValidator
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)}.");
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++)
@@ -36,7 +30,7 @@ internal static class MeetingWorkflowRulesValidator
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++)
@@ -68,7 +62,7 @@ internal static class MeetingWorkflowRulesValidator
trigger.TranscriptLine is not null);
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)
{
@@ -89,7 +83,7 @@ internal static class MeetingWorkflowRulesValidator
condition.Not is not null);
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;
}
@@ -133,28 +127,28 @@ internal static class MeetingWorkflowRulesValidator
var uses = step.Uses.Trim();
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;
}
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;
}
if (string.Equals(uses, "set_property", StringComparison.OrdinalIgnoreCase))
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(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))
{
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-])",
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
.UseNoProject()
.Build();
private readonly RazorLightEngine razorEngine = MeetingWorkflowRazorEngineFactory.Create();
public async Task<string> RenderAsync(
string template,
@@ -76,7 +76,8 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
launchProfiles,
identityMergeService,
asrDiagnosticService,
configuration);
configuration,
logger: logger);
var messages = conversation
.Select(ToChatMessage)
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
@@ -9,6 +9,7 @@ using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -47,6 +48,7 @@ public sealed class WorkflowRulesEditorTools
private readonly ISpeakerIdentityMergeService? identityMergeService;
private readonly AsrDiagnosticService? asrDiagnosticService;
private readonly IConfiguration? configuration;
private readonly ILogger? logger;
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
@@ -62,7 +64,8 @@ public sealed class WorkflowRulesEditorTools
string? configDocsPath = null,
string? logDirectory = null,
string? specRootPath = null,
string? projectAgentsTemplatePath = null)
string? projectAgentsTemplatePath = null,
ILogger? logger = null)
{
this.options = options;
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
@@ -85,6 +88,7 @@ public sealed class WorkflowRulesEditorTools
this.identityMergeService = identityMergeService;
this.asrDiagnosticService = asrDiagnosticService;
this.configuration = configuration;
this.logger = logger;
}
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)
{
return "Refused: workflow rules file is not configured.";
return LogRefusal(nameof(WriteRules), "Refused: workflow rules file is not configured.");
}
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)
@@ -123,7 +127,7 @@ public sealed class WorkflowRulesEditorTools
var validation = await ValidateYamlAsync(updated);
if (validation is not null)
{
return validation;
return LogRefusal(nameof(WriteRules), validation);
}
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
@@ -163,13 +167,13 @@ public sealed class WorkflowRulesEditorTools
{
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);
if (validation is not null)
{
return validation;
return LogRefusal(nameof(WriteConfig), validation);
}
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);
@@ -177,6 +181,15 @@ public sealed class WorkflowRulesEditorTools
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)
{
if (!File.Exists(configDocsPath))