Add transcript line workflow rules
PR and Push Build/Test / build-and-test (push) Failing after 13m27s

This commit is contained in:
2026-06-03 11:24:11 +02:00
parent 50daa88389
commit 40853115c5
20 changed files with 627 additions and 70 deletions
@@ -187,6 +187,22 @@ public sealed class MeetingWorkflowEngineTests
Assert.Contains("Speaker Ada was identified in Architecture Sync.", context);
}
[Fact]
public async Task TranscriptLineRuleCanReplaceMaskedProfanityBeforeWriting()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:04] Guest-1: Azure returned ***** here.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line);
}
[Fact]
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
{
@@ -0,0 +1,19 @@
namespace MeetingAssistant.Tests;
internal static class MeetingWorkflowTestRules
{
public const string MaskedProfanityRedactionYaml =
"""
rules:
- name: redact-masked-profanity
on:
- transcript_line: {}
if:
- condition: contains(transcript.line, '*****')
- condition: transcript.speaker = 'Guest-1'
steps:
- uses: set_property
property: transcript.line
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
""";
}
@@ -61,6 +61,25 @@ public sealed class RecordingCoordinatorTests
}
}
private static TranscriptionSegment ParseTranscriptLine(string line)
{
var bracket = line.IndexOf(']');
var rest = bracket >= 0 && bracket + 1 < line.Length
? line[(bracket + 1)..].TrimStart()
: line;
var separator = rest.IndexOf(": ", StringComparison.Ordinal);
if (separator < 0)
{
return new TranscriptionSegment(TimeSpan.Zero, TimeSpan.Zero, "Unknown", rest);
}
return new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
rest[..separator],
rest[(separator + 2)..]);
}
[Fact]
public async Task ToggleStartsStreamingTranscriptionAndSecondToggleStopsIt()
{
@@ -97,6 +116,70 @@ public sealed class RecordingCoordinatorTests
Assert.False(stopped.IsRecording);
}
[Fact]
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptBeforePersistingMarkdown()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var rulesPath = Path.Combine(root, "rules.yaml");
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(rulesPath, MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var options = new MeetingAssistantOptions
{
Vault =
{
BaseFolder = root,
MeetingNotesFolder = "Notes",
TranscriptsFolder = "Transcripts",
AssistantContextFolder = "Context",
SummariesFolder = "Summaries"
},
Automation =
{
RulesPath = rulesPath
}
};
var audioSource = new ControlledAudioSource();
var noteStore = new MarkdownMeetingNoteStore(
Options.Create(options),
NullLogger<MarkdownMeetingNoteStore>.Instance);
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new FixedSegmentStreamingTranscriptionProvider(
new TranscriptionSegment(
TimeSpan.FromSeconds(4),
TimeSpan.FromSeconds(5),
"Guest-1",
"Azure returned ***** here."))),
new VaultTranscriptStore(
Options.Create(options),
NullLogger<VaultTranscriptStore>.Instance),
noteStore,
new CapturingMeetingNoteOpener(),
artifactStore,
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingWorkflowEngine: new MeetingWorkflowEngine(
new FileMeetingWorkflowRulesProvider(
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
NullLogger<MeetingWorkflowEngine>.Instance));
var started = await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await WaitUntilAsync(() => FileContainsText(started.TranscriptPath!, "[redacted]"));
await coordinator.StopAsync(CancellationToken.None);
var content = await File.ReadAllTextAsync(started.TranscriptPath!);
Assert.Contains("[00:00:04] Guest-1: Azure returned [redacted] here.", content);
Assert.DoesNotContain("*****", content);
}
[Fact]
public async Task StartCreatesMeetingNoteLinkedToTranscriptAndOpensIt()
{
@@ -1897,7 +1980,7 @@ public sealed class RecordingCoordinatorTests
}
[Fact]
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsLines()
{
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var store = new VaultTranscriptStore(
@@ -1908,10 +1991,7 @@ public sealed class RecordingCoordinatorTests
NullLogger<VaultTranscriptStore>.Instance);
var session = await store.CreateSessionAsync(CancellationToken.None);
await store.AppendAsync(
session,
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", "hello vault"),
CancellationToken.None);
await store.AppendLineAsync(session, "[00:00:00] Unknown: hello vault", CancellationToken.None);
Assert.True(Directory.Exists(vaultFolder));
Assert.EndsWith(".md", session.TranscriptPath, StringComparison.Ordinal);
@@ -2079,9 +2159,9 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession(transcriptPath));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
segments.Add(segment);
segments.Add(ParseTranscriptLine(line));
segmentWritten.TrySetResult();
return Task.CompletedTask;
}
@@ -2108,12 +2188,12 @@ public sealed class RecordingCoordinatorTests
public MeetingNote? MetadataMeetingNote { get; private set; }
public Task ReplaceAsync(
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken)
{
ReplacedSegments = replacementSegments;
ReplacedSegments = replacementLines.Select(ParseTranscriptLine).ToList();
return Task.CompletedTask;
}
@@ -2146,9 +2226,9 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
AppendHistory.Add(new TranscriptWrite(session, segment));
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
Interlocked.Increment(ref appendCount);
appendObserved.TrySetResult();
return Task.CompletedTask;
@@ -2175,12 +2255,14 @@ public sealed class RecordingCoordinatorTests
throw new TimeoutException($"Expected {expectedCount} transcript append(s).");
}
public Task ReplaceAsync(
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken)
{
ReplacementHistory.Add(new TranscriptReplacement(session, replacementSegments));
ReplacementHistory.Add(new TranscriptReplacement(
session,
replacementLines.Select(ParseTranscriptLine).ToList()));
return Task.CompletedTask;
}
@@ -2288,6 +2370,15 @@ public sealed class RecordingCoordinatorTests
Events.Add(workflowEvent);
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Events.Add(workflowEvent);
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
}
private sealed class CapturingMeetingScreenshotService : IMeetingScreenshotService
@@ -2802,14 +2893,14 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md")));
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task ReplaceAsync(
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -612,6 +612,36 @@ public sealed class WorkflowRulesEditorTests
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
}
[Fact]
public async Task RulesEditorToolsRefuseTranscriptLinePropertyOutsideTranscriptLineTrigger()
{
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, "rules: []");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
});
var result = await tools.WriteRules("""
rules:
- name: broken-transcript-property
on:
- created: {}
steps:
- uses: set_property
property: transcript.line
value: redacted
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains("broken-transcript-property", result);
Assert.Contains("transcript.line", result);
Assert.Contains("transcript_line", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
}
[Fact]
public async Task RulesEditorToolsRefuseMalformedRazorStepValues()
{
+43 -1
View File
@@ -14,11 +14,30 @@ public interface ITranscriptStore
return CreateSessionAsync(cancellationToken);
}
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
return AppendLineAsync(
session,
TranscriptLineFormatter.Format(segment).Line,
cancellationToken);
}
Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken);
Task ReplaceAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
CancellationToken cancellationToken)
{
return ReplaceLinesAsync(
session,
replacementSegments.Select(segment => TranscriptLineFormatter.Format(segment).Line).ToList(),
cancellationToken);
}
Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken);
Task UpdateMetadataAsync(
@@ -29,3 +48,26 @@ public interface ITranscriptStore
}
public sealed record TranscriptSession(string TranscriptPath);
public static class TranscriptLineFormatter
{
public static FormattedTranscriptLine Format(TranscriptionSegment segment)
{
var speaker = NormalizeSpeaker(segment.Speaker);
return new FormattedTranscriptLine(
$"[{segment.Start:hh\\:mm\\:ss}] {speaker}: {segment.Text}",
speaker);
}
public static string FormatSegmentLine(TranscriptionSegment segment)
{
return Format(segment).Line;
}
public static string NormalizeSpeaker(string? speaker)
{
return string.IsNullOrWhiteSpace(speaker) ? "Unknown" : speaker;
}
}
public sealed record FormattedTranscriptLine(string Line, string Speaker);
@@ -433,7 +433,7 @@ public sealed class MeetingRecordingCoordinator
"System",
$"Transcription profile changed to {targetProfile.Name}.");
run.AddLiveSegment(marker);
await transcriptStore.AppendAsync(run.Session, marker, cancellationToken);
await AppendTranscriptSegmentAsync(run, marker, cancellationToken);
run.ReplacePipeline(pipeline, pipelineOptions, targetProfile.Options, targetProfile.Name);
run.AddLiveTranscriptTask(Task.Run(
@@ -598,10 +598,48 @@ public sealed class MeetingRecordingCoordinator
var relabeledSegment = run.Relabel(segment);
run.AddLiveSegment(relabeledSegment);
await transcriptStore.AppendAsync(run.Session, relabeledSegment, cancellationToken);
await AppendTranscriptSegmentAsync(run, relabeledSegment, cancellationToken);
}
}
private async Task AppendTranscriptSegmentAsync(
RecordingRun run,
TranscriptionSegment segment,
CancellationToken cancellationToken)
{
var line = await TransformTranscriptLineAsync(run, segment, cancellationToken);
await transcriptStore.AppendLineAsync(run.Session, line, cancellationToken);
}
private async Task ReplaceTranscriptSegmentsAsync(
RecordingRun run,
IReadOnlyList<TranscriptionSegment> segments,
CancellationToken cancellationToken)
{
var lines = new List<string>(segments.Count);
foreach (var segment in segments)
{
lines.Add(await TransformTranscriptLineAsync(run, segment, cancellationToken));
}
await transcriptStore.ReplaceLinesAsync(run.Session, lines, cancellationToken);
}
private async Task<string> TransformTranscriptLineAsync(
RecordingRun run,
TranscriptionSegment segment,
CancellationToken cancellationToken)
{
var formatted = TranscriptLineFormatter.Format(segment);
return await meetingWorkflowEngine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
run.Artifacts,
formatted.Line,
formatted.Speaker),
run.Options,
cancellationToken);
}
private async Task RunInactivitySafeguardAsync(RecordingRun run)
{
var safeguardOptions = run.Options.Recording.InactivitySafeguard;
@@ -778,8 +816,8 @@ public sealed class MeetingRecordingCoordinator
cancellationToken);
if (run.AddSpeakerMappings(result.SpeakerMappings))
{
await transcriptStore.ReplaceAsync(
run.Session,
await ReplaceTranscriptSegmentsAsync(
run,
run.GetRelabeledLiveSegmentsSnapshot(),
cancellationToken);
}
@@ -995,7 +1033,7 @@ public sealed class MeetingRecordingCoordinator
liveSegments.Count,
liveSamples.Count,
liveMappings.Count);
await transcriptStore.ReplaceAsync(run.Session, liveSegments, cancellationToken);
await ReplaceTranscriptSegmentsAsync(run, liveSegments, cancellationToken);
return;
}
@@ -1040,7 +1078,7 @@ public sealed class MeetingRecordingCoordinator
finishedSpeakerResult.Segments.Count,
samples.Count,
knownSpeakerMappings.Count);
await transcriptStore.ReplaceAsync(run.Session, finishedSpeakerResult.Segments, cancellationToken);
await ReplaceTranscriptSegmentsAsync(run, finishedSpeakerResult.Segments, cancellationToken);
}
private async Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersForTranscriptAsync(
@@ -1177,7 +1215,7 @@ public sealed class MeetingRecordingCoordinator
run.MeetingNotePath,
run.Options,
cancellationToken);
await transcriptStore.ReplaceAsync(run.Session, result.Segments, cancellationToken);
await ReplaceTranscriptSegmentsAsync(run, result.Segments, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
@@ -35,14 +35,14 @@ public sealed class VaultTranscriptStore : ITranscriptStore
return new TranscriptSession(path);
}
public Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{
return AppendToBodyAsync(session.TranscriptPath, FormatSegment(segment), cancellationToken);
return AppendToBodyAsync(session.TranscriptPath, line + Environment.NewLine, cancellationToken);
}
public Task ReplaceAsync(
public Task ReplaceLinesAsync(
TranscriptSession session,
IReadOnlyList<TranscriptionSegment> replacementSegments,
IReadOnlyList<string> replacementLines,
CancellationToken cancellationToken)
{
var existing = File.Exists(session.TranscriptPath)
@@ -52,7 +52,7 @@ public sealed class VaultTranscriptStore : ITranscriptStore
var body = "# Meeting Transcript"
+ Environment.NewLine
+ Environment.NewLine
+ string.Concat(replacementSegments.Select(FormatSegment));
+ string.Concat(replacementLines.Select(line => line + Environment.NewLine));
var content = string.IsNullOrWhiteSpace(frontmatter)
? body
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + body;
@@ -80,12 +80,6 @@ public sealed class VaultTranscriptStore : ITranscriptStore
cancellationToken);
}
private static string FormatSegment(TranscriptionSegment segment)
{
var speaker = string.IsNullOrWhiteSpace(segment.Speaker) ? "Unknown" : segment.Speaker;
return $"[{segment.Start:hh\\:mm\\:ss}] {speaker}: {segment.Text}{Environment.NewLine}";
}
private static async Task AppendToBodyAsync(
string path,
string text,
@@ -12,6 +12,11 @@ public interface IMeetingWorkflowEngine
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
@@ -25,6 +30,14 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
{
return Task.CompletedTask;
}
public Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
}
}
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
@@ -39,7 +52,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"event.type",
"state.from",
"state.to",
"speaker.name"
"speaker.name",
"transcript.line",
"transcript.speaker"
];
private readonly IMeetingWorkflowRulesProvider rulesProvider;
@@ -64,11 +79,35 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
_ = await RunCoreAsync(workflowEvent, options, cancellationToken);
}
public async Task<string> TransformTranscriptLineAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
if (workflowEvent.Type != MeetingWorkflowEventType.TranscriptLine)
{
throw new ArgumentException("Workflow event must be a transcript line event.", nameof(workflowEvent));
}
return await RunCoreAsync(workflowEvent, options, cancellationToken)
?? workflowEvent.TranscriptLineText
?? "";
}
private async Task<string?> RunCoreAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var rules = await rulesProvider.GetRulesAsync(options, cancellationToken);
var context = new MeetingWorkflowExecutionContext(workflowEvent);
if (rules.Count == 0)
{
return;
return context.TranscriptLine;
}
var meeting = await meetingNoteStore.ReadAsync(
@@ -79,7 +118,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
foreach (var rule in rules)
{
if (!MatchesTrigger(rule, workflowEvent) ||
!EvaluateConditions(rule.If, meeting, workflowEvent))
!EvaluateConditions(rule.If, meeting, context))
{
continue;
}
@@ -88,16 +127,16 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
"Applying meeting workflow rule {RuleName} for event {EventType}",
rule.Name,
workflowEvent.Type);
var model = CreateTemplateModel(meeting, workflowEvent);
var model = CreateTemplateModel(meeting, context);
foreach (var step in rule.Steps)
{
noteChanged |= await ApplyStepAsync(
step,
meeting,
workflowEvent,
context,
model,
cancellationToken);
model = CreateTemplateModel(meeting, workflowEvent);
model = CreateTemplateModel(meeting, context);
}
}
@@ -109,6 +148,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
saved,
cancellationToken);
}
return context.TranscriptLine;
}
private static bool MatchesTrigger(
@@ -151,41 +192,51 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
StringComparison.OrdinalIgnoreCase));
}
if (trigger.TranscriptLine is not null)
{
return workflowEvent.Type == MeetingWorkflowEventType.TranscriptLine &&
(string.IsNullOrWhiteSpace(trigger.TranscriptLine.Speaker) ||
string.Equals(
trigger.TranscriptLine.Speaker.Trim(),
workflowEvent.SpeakerName,
StringComparison.OrdinalIgnoreCase));
}
return false;
}
private bool EvaluateConditions(
IReadOnlyList<MeetingWorkflowCondition> conditions,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
return conditions.Count == 0 ||
conditions.All(condition => EvaluateCondition(condition, meeting, workflowEvent));
conditions.All(condition => EvaluateCondition(condition, meeting, context));
}
private bool EvaluateCondition(
MeetingWorkflowCondition condition,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
if (!string.IsNullOrWhiteSpace(condition.Condition))
{
return EvaluateExpression(condition.Condition, meeting, workflowEvent);
return EvaluateExpression(condition.Condition, meeting, context);
}
if (condition.And is { Count: > 0 })
{
return condition.And.All(child => EvaluateCondition(child, meeting, workflowEvent));
return condition.And.All(child => EvaluateCondition(child, meeting, context));
}
if (condition.Or is { Count: > 0 })
{
return condition.Or.Any(child => EvaluateCondition(child, meeting, workflowEvent));
return condition.Or.Any(child => EvaluateCondition(child, meeting, context));
}
if (condition.Not is not null)
{
return !EvaluateCondition(condition.Not, meeting, workflowEvent);
return !EvaluateCondition(condition.Not, meeting, context);
}
return true;
@@ -194,10 +245,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private bool EvaluateExpression(
string expressionText,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var expression = new Expression(PrepareExpression(expressionText));
foreach (var (name, value) in BuildParameters(meeting, workflowEvent))
foreach (var (name, value) in BuildParameters(meeting, context))
{
expression.Parameters[name] = value;
}
@@ -219,7 +270,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private async Task<bool> ApplyStepAsync(
MeetingWorkflowStep step,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent,
MeetingWorkflowExecutionContext context,
MeetingWorkflowTemplateModel model,
CancellationToken cancellationToken)
{
@@ -232,14 +283,17 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
return RemoveValue(meeting.Frontmatter.Attendees, value);
case "add_project":
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
case "set_property":
return SetProperty(meeting, step.Property ?? step.Name, value);
case "add_context":
await meetingArtifactStore.AppendAssistantContextAsync(
workflowEvent.Artifacts,
context.Event.Artifacts,
value,
cancellationToken);
return false;
case "set_property" when IsTranscriptLineProperty(step.Property ?? step.Name):
SetTranscriptLine(context, value);
return false;
case "set_property":
return SetProperty(meeting, step.Property ?? step.Name, value);
default:
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
}
@@ -272,6 +326,28 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
throw new InvalidOperationException($"Unsupported meeting workflow property '{property}'.");
}
private static void SetTranscriptLine(
MeetingWorkflowExecutionContext context,
string value)
{
if (context.Event.Type != MeetingWorkflowEventType.TranscriptLine)
{
throw new InvalidOperationException("The transcript.line property can only be set during transcript_line events.");
}
if (string.Equals(context.TranscriptLine, value, StringComparison.Ordinal))
{
return;
}
context.TranscriptLine = value;
}
private static bool IsTranscriptLineProperty(string? property)
{
return string.Equals(property?.Trim(), "transcript.line", StringComparison.OrdinalIgnoreCase);
}
private static bool AddUnique(
List<string> values,
string value,
@@ -338,8 +414,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
private static IReadOnlyDictionary<string, object?> BuildParameters(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var workflowEvent = context.Event;
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["meeting.attendees.count"] = meeting.Frontmatter.Attendees.Count,
@@ -350,14 +427,17 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
["event.type"] = workflowEvent.Type.ToString(),
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
["speaker.name"] = workflowEvent.SpeakerName
["speaker.name"] = workflowEvent.SpeakerName,
["transcript.line"] = context.TranscriptLine ?? "",
["transcript.speaker"] = workflowEvent.SpeakerName ?? ""
};
}
private static MeetingWorkflowTemplateModel CreateTemplateModel(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
MeetingWorkflowExecutionContext context)
{
var workflowEvent = context.Event;
return new MeetingWorkflowTemplateModel(
new MeetingWorkflowMeetingModel(
meeting.Frontmatter.Title,
@@ -370,6 +450,22 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : null),
string.IsNullOrWhiteSpace(workflowEvent.SpeakerName)
? null
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName));
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName),
new MeetingWorkflowTranscriptModel(
context.TranscriptLine ?? "",
workflowEvent.SpeakerName ?? ""));
}
private sealed class MeetingWorkflowExecutionContext
{
public MeetingWorkflowExecutionContext(MeetingWorkflowEvent workflowEvent)
{
Event = workflowEvent;
TranscriptLine = workflowEvent.TranscriptLineText;
}
public MeetingWorkflowEvent Event { get; }
public string? TranscriptLine { get; set; }
}
}
@@ -6,7 +6,8 @@ public enum MeetingWorkflowEventType
{
Created,
StateTransition,
SpeakerIdentified
SpeakerIdentified,
TranscriptLine
}
public sealed record MeetingWorkflowEvent(
@@ -14,7 +15,8 @@ public sealed record MeetingWorkflowEvent(
MeetingSessionArtifacts Artifacts,
AssistantContextState? FromState = null,
AssistantContextState? ToState = null,
string? SpeakerName = null)
string? SpeakerName = null,
string? TranscriptLineText = null)
{
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
{
@@ -35,4 +37,16 @@ public sealed record MeetingWorkflowEvent(
{
return new MeetingWorkflowEvent(MeetingWorkflowEventType.SpeakerIdentified, artifacts, SpeakerName: speakerName);
}
public static MeetingWorkflowEvent TranscriptLine(
MeetingSessionArtifacts artifacts,
string line,
string speakerName)
{
return new MeetingWorkflowEvent(
MeetingWorkflowEventType.TranscriptLine,
artifacts,
SpeakerName: speakerName,
TranscriptLineText: line);
}
}
@@ -34,6 +34,9 @@ public sealed class MeetingWorkflowTrigger
[YamlMember(Alias = "speaker_identified")]
public MeetingWorkflowSpeakerIdentifiedTrigger? SpeakerIdentified { get; set; }
[YamlMember(Alias = "transcript_line")]
public MeetingWorkflowTranscriptLineTrigger? TranscriptLine { get; set; }
}
public sealed class MeetingWorkflowStateTransitionTrigger
@@ -51,6 +54,12 @@ public sealed class MeetingWorkflowSpeakerIdentifiedTrigger
public string? Name { get; set; }
}
public sealed class MeetingWorkflowTranscriptLineTrigger
{
[YamlMember(Alias = "speaker")]
public string? Speaker { get; set; }
}
public sealed class MeetingWorkflowCondition
{
[YamlMember(Alias = "condition")]
@@ -84,7 +93,8 @@ public sealed class MeetingWorkflowStep
public sealed record MeetingWorkflowTemplateModel(
MeetingWorkflowMeetingModel Meeting,
MeetingWorkflowEventModel Event,
MeetingWorkflowSpeakerModel? Speaker);
MeetingWorkflowSpeakerModel? Speaker,
MeetingWorkflowTranscriptModel? Transcript);
public sealed record MeetingWorkflowMeetingModel(
string Title,
@@ -99,6 +109,8 @@ public sealed record MeetingWorkflowEventModel(
public sealed record MeetingWorkflowSpeakerModel(string Name);
public sealed record MeetingWorkflowTranscriptModel(string Line, string Speaker);
internal static class MeetingWorkflowStateNames
{
public static string ToRuleName(AssistantContextState state)
@@ -4,11 +4,11 @@ namespace MeetingAssistant.Workflow;
internal static class MeetingWorkflowRulesValidator
{
private static readonly string[] SupportedTriggerKeys = ["created", "state_transition", "speaker_identified"];
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"];
private static readonly string[] SupportedSetPropertyNames = ["title", "meeting.title", "transcript.line"];
public static async Task<IReadOnlyList<string>> ValidateAsync(MeetingWorkflowRulesFile rulesFile)
{
@@ -43,6 +43,7 @@ internal static class MeetingWorkflowRulesValidator
{
await ValidateStepAsync(
rule.Steps[stepIndex],
rule,
ruleName,
stepIndex,
errors,
@@ -63,7 +64,8 @@ internal static class MeetingWorkflowRulesValidator
var configuredCount = CountConfigured(
trigger.Created is not null,
trigger.StateTransition is not null,
trigger.SpeakerIdentified 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)}.");
@@ -120,6 +122,7 @@ internal static class MeetingWorkflowRulesValidator
private static async Task ValidateStepAsync(
MeetingWorkflowStep step,
MeetingWorkflowRule rule,
string ruleName,
int stepIndex,
List<string> errors,
@@ -151,6 +154,11 @@ internal static class MeetingWorkflowRulesValidator
{
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 &&
@@ -174,6 +182,11 @@ internal static class MeetingWorkflowRulesValidator
: $"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);
@@ -196,7 +209,10 @@ internal static class MeetingWorkflowRulesValidator
MeetingWorkflowEventType.StateTransition.ToString(),
MeetingWorkflowStateNames.ToRuleName(AssistantContextState.CollectingMetadata),
MeetingWorkflowStateNames.ToRuleName(AssistantContextState.Transcribing)),
new MeetingWorkflowSpeakerModel("Ada Lovelace"));
new MeetingWorkflowSpeakerModel("Ada Lovelace"),
new MeetingWorkflowTranscriptModel(
"[00:00:01] Ada Lovelace: Validation line.",
"Ada Lovelace"));
}
private static string FormatRazorValidationMessage(Exception exception)
+1
View File
@@ -19,6 +19,7 @@ The application is intended to:
- use a configurable speech recognition pipeline, with local Whisper as the first version 1 fallback
- use FunASR or local Whisper plus pyannote as configurable speaker-attribution paths
- write transcript markdown files into the configured vault folder
- apply local workflow rules to meeting metadata, context, and transcript lines
- generate summaries, decisions, and next steps
- build and maintain a project knowledge base from meetings and future context sources
- use Microsoft Agent Framework-based agents to reason over meeting and project context
+1 -1
View File
@@ -287,7 +287,7 @@ Speaker identity matching keeps candidate samples only after a diarized speaker
## Automation
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`.
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, or transcript line writes; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, `Model.Speaker`, and `Model.Transcript`.
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
+40 -1
View File
@@ -68,6 +68,7 @@ The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow even
- `created`: after the meeting note and assistant context artifact are created.
- `state_transition`: after the assistant context state moves forward.
- `speaker_identified`: when live or final speaker identification reports a display name.
- `transcript_line`: before a formatted transcript line is appended or used in a transcript rewrite.
For every event, the engine:
@@ -79,6 +80,8 @@ For every event, the engine:
6. Saves the meeting note once if any meeting-note step changed it.
7. Updates assistant-context frontmatter links/title from the saved meeting note after note changes.
For `transcript_line` events, the engine returns the possibly updated transcript line to the caller. Live transcript appends, live speaker relabel rewrites, final diarization rewrites, and final speaker-identity rewrites all pass their formatted lines through this event before writing markdown.
Rules are best-effort automation. The settings/logs write tool rejects invalid YAML, unknown steps, unsupported set-property fields, trigger or condition entries without a supported key, and step value Razor templates that fail against the workflow template model before writing the rules file. Invalid NCalc expressions can still fail at workflow runtime and should be covered by tests before the engine is broadened.
## YAML Shape
@@ -145,6 +148,16 @@ on:
name: Ada
```
### `transcript_line`
Runs before a formatted transcript line is written. `speaker` is optional; when supplied, it matches the effective speaker label case-insensitively.
```yaml
on:
- transcript_line:
speaker: Guest-1
```
## Conditions
Conditions use NCalc expressions. Dotted workflow variables may be written directly; the engine rewrites them into NCalc parameters internally.
@@ -165,6 +178,8 @@ Available condition variables:
- `state.from`
- `state.to`
- `speaker.name`
- `transcript.line`
- `transcript.speaker`
Available helper functions:
@@ -196,6 +211,8 @@ Step `value` fields can be plain strings or Razor templates. Razor templates rec
- `Model.Event.From`
- `Model.Event.To`
- `Model.Speaker.Name`
- `Model.Transcript.Line`
- `Model.Transcript.Speaker`
Example:
@@ -234,7 +251,7 @@ steps:
### `set_property`
Sets a supported meeting property. The initial supported property is `title` or `meeting.title`.
Sets a supported property. Supported meeting properties are `title` and `meeting.title`. During `transcript_line` events, `transcript.line` is also supported and replaces the single formatted line that will be written.
```yaml
steps:
@@ -243,6 +260,13 @@ steps:
value: "@Model.Meeting.Title.Replace('[External] ', '')"
```
```yaml
steps:
- uses: set_property
property: transcript.line
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
```
### `add_context`
Appends rendered text to the assistant context artifact body. This step does not count as a meeting-note mutation and does not cause a meeting-note save by itself.
@@ -323,6 +347,21 @@ rules:
value: "Manuel is attending; include implementation follow-ups in the summary."
```
Replace Azure Speech masked profanity before transcript markdown is written:
```yaml
rules:
- name: redact-masked-profanity
on:
- transcript_line: {}
if:
- condition: contains(transcript.line, '*****')
steps:
- uses: set_property
property: transcript.line
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
```
## Implementation Notes
Core files:
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-03
@@ -0,0 +1,23 @@
# Design: Transcript write workflow rules
## Approach
Meeting Assistant should keep transcript redaction policy in the local workflow rules file instead of hard-coding it in the Azure Speech provider. The provider still emits the text it receives from Azure, and the persistence path asks the workflow engine to transform the formatted transcript line before writing it.
The workflow engine will add a `transcript_line` trigger. The event carries the formatted line without a trailing newline and the effective speaker name used in that line. Conditions receive `transcript.line` and `transcript.speaker`; Razor templates receive `Model.Transcript.Line` and `Model.Transcript.Speaker`.
The existing `set_property` step is extended with `transcript.line`. This keeps the action surface small while enabling replacements such as:
```yaml
steps:
- uses: set_property
property: transcript.line
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
```
## Write Path
Live transcript appends and finished transcript rewrites both format `TranscriptionSegment` values into single transcript lines, pass each line through the workflow engine, and then write the transformed lines. This prevents a final diarization rewrite from reintroducing untransformed masked text.
## Constraints
- Transcript line rules do not mutate meeting-note frontmatter unless they also include existing meeting-note steps.
- The transcript line model represents one formatted line, not the whole transcript file.
- The local rules file remains ignored by git; repository documentation covers the rule shape, while the user-specific redaction rule lives in `meeting-rules.local.yaml`.
@@ -0,0 +1,15 @@
# Change: Add transcript write workflow rules
## Why
Azure Speech can emit masked profanity as `*****`. In markdown transcript files, that token can be interpreted as formatting instead of normal transcript text, which makes the generated transcript harder to read and summarize.
## What Changes
- Add a workflow trigger for transcript line writes.
- Expose the formatted transcript line and speaker name to workflow conditions and Razor step templates.
- Allow workflow rules to set the transcript line before it is appended or rewritten.
- Add a default local rule that replaces `*****` with `[redacted]`.
## Impact
- Extends the meeting workflow engine event model.
- Routes live transcript appends and finished transcript rewrites through the workflow engine before writing lines.
- Updates workflow documentation and rule validation.
@@ -0,0 +1,81 @@
## MODIFIED Requirements
### Requirement: Meeting automation rules support lifecycle triggers
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, `speaker_identified`, and `transcript_line`.
A `state_transition` trigger MAY filter by `from`, `to`, or both state values.
A `speaker_identified` trigger MAY filter by speaker name.
A `transcript_line` trigger MAY filter by speaker name.
#### Scenario: State transition rule matches from and to
- **GIVEN** a configured rule that triggers on a state transition from `collecting metadata` to `transcribing`
- **WHEN** Meeting Assistant transitions that meeting from `collecting metadata` to `transcribing`
- **THEN** it applies the rule steps
#### Scenario: Speaker identified rule filters by name
- **GIVEN** a configured rule that triggers when speaker `Ada` is identified
- **WHEN** Meeting Assistant identifies speaker `Grace`
- **THEN** it does not apply the rule
- **WHEN** Meeting Assistant identifies speaker `Ada`
- **THEN** it applies the rule steps
#### Scenario: Transcript line rule rewrites masked profanity before persistence
- **GIVEN** a configured rule that triggers on transcript line writes and sets `transcript.line` by replacing `*****` with `[redacted]`
- **WHEN** Meeting Assistant writes a transcript line for speaker `Guest-1` containing `*****`
- **THEN** the written transcript line contains `[redacted]`
- **AND** the written transcript line does not contain `*****`
### Requirement: Meeting automation rules support conditions and steps
Meeting Assistant SHALL support rule conditions using an expression engine.
Rules SHALL support nested `and`, `or`, and `not` condition groups.
Step values SHALL support Razor syntax against the current meeting event model.
Step values SHALL treat `@` characters inside valid email address tokens as literal text rather than Razor transitions.
Meeting Assistant SHALL expose the formatted transcript line and transcript speaker to `transcript_line` rule conditions and Razor step templates.
Meeting Assistant SHALL support these initial rule steps:
- `add_attendee`
- `remove_attendee`
- `set_property`
- `add_context`
- `add_project`
The `set_property` step SHALL support setting `transcript.line` during `transcript_line` events.
#### Scenario: Nested conditions choose a matching rule
- **GIVEN** a configured rule with nested `and`, `or`, and `not` conditions over meeting title, attendees, and event data
- **WHEN** the condition evaluates to true
- **THEN** Meeting Assistant applies the rule
- **WHEN** the condition evaluates to false
- **THEN** Meeting Assistant skips the rule
#### Scenario: Templated context mentions identified speaker
- **GIVEN** a configured `speaker_identified` rule with an `add_context` step using Razor syntax
- **WHEN** Meeting Assistant identifies matching speaker `Ada`
- **THEN** it appends rendered context text containing `Ada` to the assistant context note
#### Scenario: Email addresses do not trigger Razor templating
- **GIVEN** a configured rule step value containing `Support@contoso.com`
- **WHEN** the rule runs
- **THEN** Meeting Assistant preserves the email address as literal text
#### Scenario: Email addresses can appear beside Razor templating
- **GIVEN** a configured rule step value containing both `Support@contoso.com` and a Razor expression
- **WHEN** the rule runs
- **THEN** Meeting Assistant renders the Razor expression and preserves the email address as literal text
#### Scenario: Rule can clean a meeting title
- **GIVEN** a configured state-transition rule that matches a title containing a configured marker
- **WHEN** the rule runs
- **THEN** Meeting Assistant can update the meeting title through `set_property`
#### Scenario: Transcript line conditions can use the written line and speaker
- **GIVEN** a configured `transcript_line` rule with conditions over `transcript.line` and `transcript.speaker`
- **WHEN** Meeting Assistant writes a transcript line that matches both conditions
- **THEN** it applies the rule steps before the line is persisted
@@ -0,0 +1,11 @@
## 1. Implementation
- [x] 1.1 Add OpenSpec delta requirements for transcript line workflow rules.
- [x] 1.2 Extend workflow triggers, condition parameters, template model, and `set_property` support for transcript lines.
- [x] 1.3 Route live transcript appends and finished transcript rewrites through transcript line workflow transformation.
- [x] 1.4 Add the local profanity redaction rule.
- [x] 1.5 Update workflow engine documentation and configuration docs.
## 2. Verification
- [x] 2.1 Add behavior coverage for transcript line trigger matching, conditions, templating, and line mutation.
- [x] 2.2 Run focused workflow/recording tests.
- [x] 2.3 Run `openspec validate add-transcript-write-workflow-rules --strict`.
+18 -1
View File
@@ -179,12 +179,14 @@ Meeting Assistant SHALL expose a diagnostic endpoint that reloads application co
- **AND** future workflow events use the reloaded workflow automation configuration
### Requirement: Meeting automation rules support lifecycle triggers
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, and `speaker_identified`.
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, `speaker_identified`, and `transcript_line`.
A `state_transition` trigger MAY filter by `from`, `to`, or both state values.
A `speaker_identified` trigger MAY filter by speaker name.
A `transcript_line` trigger MAY filter by speaker name.
#### Scenario: State transition rule matches from and to
- **GIVEN** a configured rule that triggers on a state transition from `collecting metadata` to `transcribing`
- **WHEN** Meeting Assistant transitions that meeting from `collecting metadata` to `transcribing`
@@ -197,6 +199,12 @@ A `speaker_identified` trigger MAY filter by speaker name.
- **WHEN** Meeting Assistant identifies speaker `Ada`
- **THEN** it applies the rule steps
#### Scenario: Transcript line rule rewrites masked profanity before persistence
- **GIVEN** a configured rule that triggers on transcript line writes and sets `transcript.line` by replacing `*****` with `[redacted]`
- **WHEN** Meeting Assistant writes a transcript line for speaker `Guest-1` containing `*****`
- **THEN** the written transcript line contains `[redacted]`
- **AND** the written transcript line does not contain `*****`
### Requirement: Meeting automation rules support conditions and steps
Meeting Assistant SHALL support rule conditions using an expression engine.
@@ -206,6 +214,8 @@ Step values SHALL support Razor syntax against the current meeting event model.
Step values SHALL treat `@` characters inside valid email address tokens as literal text rather than Razor transitions.
Meeting Assistant SHALL expose the formatted transcript line and transcript speaker to `transcript_line` rule conditions and Razor step templates.
Meeting Assistant SHALL support these initial rule steps:
- `add_attendee`
@@ -214,6 +224,8 @@ Meeting Assistant SHALL support these initial rule steps:
- `add_context`
- `add_project`
The `set_property` step SHALL support setting `transcript.line` during `transcript_line` events.
#### Scenario: Nested conditions choose a matching rule
- **GIVEN** a configured rule with nested `and`, `or`, and `not` conditions over meeting title, attendees, and event data
- **WHEN** the condition evaluates to true
@@ -241,6 +253,11 @@ Meeting Assistant SHALL support these initial rule steps:
- **WHEN** the rule runs
- **THEN** Meeting Assistant can update the meeting title through `set_property`
#### Scenario: Transcript line conditions can use the written line and speaker
- **GIVEN** a configured `transcript_line` rule with conditions over `transcript.line` and `transcript.speaker`
- **WHEN** Meeting Assistant writes a transcript line that matches both conditions
- **THEN** it applies the rule steps before the line is persisted
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.