From 5c67738939ac92d46c31385abfa466a720327e0a Mon Sep 17 00:00:00 2001 From: Codex Automation Date: Wed, 1 Jul 2026 11:10:36 +0200 Subject: [PATCH] Add attendee transformation workflows --- .../MeetingScreenshotServiceTests.cs | 36 ++++- .../MeetingSummaryToolTests.cs | 39 +++++ .../MeetingWorkflowEngineTests.cs | 62 +++++++- .../RecordingCoordinatorTests.cs | 33 +++++ .../TransformingAttendeeWorkflowEngine.cs | 44 ++++++ .../WorkflowRulesEditorTests.cs | 22 +++ .../Recording/MeetingRecordingCoordinator.cs | 63 +++++++- .../Screenshots/MeetingScreenshotService.cs | 39 ++++- .../Summary/MeetingSummaryTools.cs | 12 +- .../OpenAiMeetingSummaryAgentPipeline.cs | 13 +- .../Workflow/MeetingWorkflowEngine.cs | 137 +++++++++++++++++- .../Workflow/MeetingWorkflowEvent.cs | 16 +- .../Workflow/MeetingWorkflowModels.cs | 20 ++- .../Workflow/MeetingWorkflowRuleSchema.cs | 7 +- .../Workflow/MeetingWorkflowRulesValidator.cs | 28 +++- README.md | 2 +- docs/meeting-workflow-engine.md | 51 ++++++- .../.openspec.yaml | 2 + .../design.md | 30 ++++ .../proposal.md | 17 +++ .../specs/meeting-session/spec.md | 29 ++++ .../tasks.md | 8 + openspec/specs/meeting-session/spec.md | 107 +++----------- 23 files changed, 694 insertions(+), 123 deletions(-) create mode 100644 MeetingAssistant.Tests/TransformingAttendeeWorkflowEngine.cs create mode 100644 openspec/changes/archive/2026-07-01-transform-added-attendees/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-01-transform-added-attendees/design.md create mode 100644 openspec/changes/archive/2026-07-01-transform-added-attendees/proposal.md create mode 100644 openspec/changes/archive/2026-07-01-transform-added-attendees/specs/meeting-session/spec.md create mode 100644 openspec/changes/archive/2026-07-01-transform-added-attendees/tasks.md diff --git a/MeetingAssistant.Tests/MeetingScreenshotServiceTests.cs b/MeetingAssistant.Tests/MeetingScreenshotServiceTests.cs index 4a2c070..926d76b 100644 --- a/MeetingAssistant.Tests/MeetingScreenshotServiceTests.cs +++ b/MeetingAssistant.Tests/MeetingScreenshotServiceTests.cs @@ -1,6 +1,7 @@ using MeetingAssistant.MeetingNotes; using MeetingAssistant.Screenshots; using MeetingAssistant.Speakers; +using MeetingAssistant.Workflow; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using System.Drawing; @@ -167,6 +168,35 @@ public sealed class MeetingScreenshotServiceTests request.SequenceEqual(["Ada Lovelace", "Ada L.", "Grace Hopper", "Ada Lovelace"])); } + [Fact] + public async Task CaptureTransformsOcrAttendeesBeforeWritingMeetingNote() + { + var fixture = await ScreenshotFixture.CreateAsync( + options => + { + options.Screenshots.Ocr.Enabled = true; + }); + var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace"); + var service = fixture.CreateService( + new FixedScreenshotCapture([1, 2, 3]), + new CapturingScreenshotOcrClient( + "Visible participant tile: Ada.", + attendees: ["Ada Lovelace (Contoso)"]), + meetingWorkflowEngine: workflowEngine); + + await service.CaptureAsync( + fixture.Artifacts, + DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"), + DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"), + fixture.Options, + CancellationToken.None); + await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None); + + var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); + Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); + Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests); + } + [Fact] public async Task CaptureWritesRetryLinkWhenOcrFails() { @@ -423,7 +453,8 @@ public sealed class MeetingScreenshotServiceTests public MeetingScreenshotService CreateService( IActiveWindowScreenshotCapture capture, IScreenshotOcrClient ocrClient, - ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null) + ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null, + IMeetingWorkflowEngine? meetingWorkflowEngine = null) { return new MeetingScreenshotService( capture, @@ -431,7 +462,8 @@ public sealed class MeetingScreenshotServiceTests NoteStore, attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance, ocrClient, - NullLogger.Instance); + NullLogger.Instance, + meetingWorkflowEngine); } } diff --git a/MeetingAssistant.Tests/MeetingSummaryToolTests.cs b/MeetingAssistant.Tests/MeetingSummaryToolTests.cs index 477024e..86c5f68 100644 --- a/MeetingAssistant.Tests/MeetingSummaryToolTests.cs +++ b/MeetingAssistant.Tests/MeetingSummaryToolTests.cs @@ -293,6 +293,44 @@ public sealed class MeetingSummaryToolTests Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", meetingNote); } + [Fact] + public async Task ToolsTransformAddedAttendeeBeforeWritingMeetingNote() + { + var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); + var artifacts = new MeetingSessionArtifacts( + MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"), + TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), + AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"), + SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md")); + Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!); + await File.WriteAllTextAsync( + artifacts.MeetingNotePath, + """ + --- + title: Meeting + attendees: [] + projects: [] + transcript: "[[../Transcripts/transcript|Transcript]]" + assistant_context: "[[../Assistant Context/context|Assistant Context]]" + summary: "[[../Summaries/summary|Summary]]" + --- + + User note line. + """); + var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace"); + var tools = new MeetingSummaryTools( + artifacts, + new MeetingAssistantOptions(), + meetingWorkflowEngine: workflowEngine); + + Assert.Equal("Added attendee Ada Lovelace.", await tools.AddAttendee("Ada Lovelace (Contoso)")); + + var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath); + Assert.Contains("- Ada Lovelace", meetingNote); + Assert.DoesNotContain("Ada Lovelace (Contoso)", meetingNote); + Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests); + } + [Fact] public async Task ToolsOverrideSpeakerInTranscriptAndRecordOverride() { @@ -799,4 +837,5 @@ public sealed class MeetingSummaryToolTests Assert.False(tools.SummaryWasWritten); Assert.False(File.Exists(artifacts.SummaryPath)); } + } diff --git a/MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs b/MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs index df64ad1..1e176de 100644 --- a/MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs +++ b/MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs @@ -310,6 +310,10 @@ public sealed class MeetingWorkflowEngineTests [InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)] [InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)] [InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)] + [InlineData("attendee_added:\n equals: Ada Lovelace", "attendee", null, null, "ada lovelace", true)] + [InlineData("attendee_added:\n contains: contoso", "attendee", null, null, "Ada (Contoso)", true)] + [InlineData("attendee_added:\n regex: '^Ada .+Contoso\\)$'", "attendee", null, null, "Ada (Contoso)", true)] + [InlineData("attendee_added:\n regex: '^Grace'", "attendee", null, null, "Ada (Contoso)", false)] public async Task TriggerMatchingScenarios( string triggerYaml, string eventKind, @@ -318,6 +322,16 @@ public sealed class MeetingWorkflowEngineTests string? speaker, bool shouldRun) { + var stepYaml = eventKind == "attendee" + ? """ + - uses: set_property + property: attendee.name + value: Triggered + """ + : """ + - uses: add_project + value: Triggered + """; var fixture = await WorkflowFixture.CreateAsync( $$""" rules: @@ -325,14 +339,21 @@ public sealed class MeetingWorkflowEngineTests on: - {{triggerYaml}} steps: - - uses: add_project - value: Triggered + {{stepYaml}} """); - await fixture.Engine.RunAsync( - CreateEvent(eventKind, fixture.Artifacts, from, to, speaker), - fixture.Options, - CancellationToken.None); + var workflowEvent = CreateEvent(eventKind, fixture.Artifacts, from, to, speaker); + if (eventKind == "attendee") + { + var transformed = await fixture.Engine.TransformAttendeeAsync( + workflowEvent, + fixture.Options, + CancellationToken.None); + Assert.Equal(shouldRun ? "Triggered" : speaker, transformed); + return; + } + + await fixture.Engine.RunAsync(workflowEvent, fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered")); @@ -603,6 +624,34 @@ public sealed class MeetingWorkflowEngineTests Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); } + [Fact] + public async Task AttendeeAddedRuleCanTransformWorkflowAddedAttendee() + { + var fixture = await WorkflowFixture.CreateAsync( + """ + rules: + - name: add-raw-attendee + on: + - created: {} + steps: + - uses: add_attendee + value: 'Ada Lovelace (Contoso)' + - name: clean-contoso-attendee + on: + - attendee_added: + contains: 'Contoso' + steps: + - uses: set_property + property: attendee.name + value: '@Model.Attendee.Name.Replace(" (Contoso)", "")' + """); + + await RunCreatedAsync(fixture); + + var meeting = await fixture.ReadMeetingAsync(); + Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); + } + [Fact] public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress() { @@ -792,6 +841,7 @@ public sealed class MeetingWorkflowEngineTests { "created" => MeetingWorkflowEvent.Created(artifacts), "speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"), + "attendee" => MeetingWorkflowEvent.AttendeeAdded(artifacts, speaker ?? "Ada"), "state" => MeetingWorkflowEvent.StateTransition( artifacts, ParseState(from ?? "collecting metadata"), diff --git a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs index 48b40fb..66e0e13 100644 --- a/MeetingAssistant.Tests/RecordingCoordinatorTests.cs +++ b/MeetingAssistant.Tests/RecordingCoordinatorTests.cs @@ -900,6 +900,39 @@ public sealed class RecordingCoordinatorTests await coordinator.StopAsync(CancellationToken.None); } + [Fact] + public async Task StartTransformsMetadataAttendeesBeforeWritingNote() + { + var audioSource = new ControlledAudioSource(); + var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md"); + var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace"); + var coordinator = new MeetingRecordingCoordinator( + audioSource, + new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()), + new InMemoryTranscriptStore(), + noteStore, + new CapturingMeetingNoteOpener(), + new InMemoryMeetingArtifactStore(), + new InMemoryRecordedAudioStore(), + new CapturingMeetingSummaryPipeline(), + Options.Create(new MeetingAssistantOptions()), + NullLogger.Instance, + meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata( + "Architecture Sync", + ["Ada Lovelace (Contoso)"], + "", + null)), + meetingWorkflowEngine: workflowEngine); + + await coordinator.StartAsync(CancellationToken.None); + await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Attendees.Count > 0); + + Assert.Equal(["Ada Lovelace"], noteStore.SavedNote?.Frontmatter.Attendees); + Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests); + + await coordinator.StopAsync(CancellationToken.None); + } + [Fact] public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit() { diff --git a/MeetingAssistant.Tests/TransformingAttendeeWorkflowEngine.cs b/MeetingAssistant.Tests/TransformingAttendeeWorkflowEngine.cs new file mode 100644 index 0000000..f3a07c9 --- /dev/null +++ b/MeetingAssistant.Tests/TransformingAttendeeWorkflowEngine.cs @@ -0,0 +1,44 @@ +using MeetingAssistant.Workflow; + +namespace MeetingAssistant.Tests; + +internal sealed class TransformingAttendeeWorkflowEngine : IMeetingWorkflowEngine +{ + private readonly string source; + private readonly string replacement; + + public TransformingAttendeeWorkflowEngine(string source, string replacement) + { + this.source = source; + this.replacement = replacement; + } + + public List AttendeeRequests { get; } = []; + + public Task RunAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task TransformTranscriptLineAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); + } + + public Task TransformAttendeeAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + AttendeeRequests.Add(workflowEvent.AttendeeName ?? ""); + return Task.FromResult(string.Equals(workflowEvent.AttendeeName, source, StringComparison.OrdinalIgnoreCase) + ? replacement + : workflowEvent.AttendeeName ?? ""); + } +} diff --git a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs index 6bc99da..1675392 100644 --- a/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs +++ b/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs @@ -637,6 +637,28 @@ public sealed class WorkflowRulesEditorTests Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); } + [Fact] + public async Task RulesEditorToolsRefuseSideEffectingAttendeeAddedRules() + { + var fixture = await CreateRulesEditorFixtureAsync(); + + var result = await fixture.Tools.WriteRules(""" + rules: + - name: attendee-side-effect + on: + - attendee_added: + contains: Contoso + steps: + - uses: add_context + value: '@Model.Attendee.Name' + """, replace_file: true); + + Assert.StartsWith("Refused: workflow rules are invalid.", result); + Assert.Contains("attendee-side-effect", result); + Assert.Contains("attendee.name", result); + Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath)); + } + [Fact] public async Task RulesEditorToolsAcceptAndParseMaskedProfanityRedactionRule() { diff --git a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs index fd4fd4f..3e002ce 100644 --- a/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs +++ b/MeetingAssistant/Recording/MeetingRecordingCoordinator.cs @@ -213,7 +213,12 @@ public sealed class MeetingRecordingCoordinator currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken); if (suppliedMetadata is not null) { - await ApplyMeetingMetadataAsync(currentMeetingNote, suppliedMetadata, runOptions, cancellationToken); + await ApplyMeetingMetadataAsync( + currentArtifacts, + currentMeetingNote, + suppliedMetadata, + runOptions, + cancellationToken); currentMeetingNote = await meetingNoteStore.SaveAsync( currentMeetingNote, runOptions, @@ -1030,7 +1035,7 @@ public sealed class MeetingRecordingCoordinator } var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None); - await ApplyMeetingMetadataAsync(meetingNote, metadata, run.Options, CancellationToken.None); + await ApplyMeetingMetadataAsync(run.Artifacts, meetingNote, metadata, run.Options, CancellationToken.None); meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None); if (currentMeetingNote?.Path == meetingNote.Path) { @@ -1082,6 +1087,7 @@ public sealed class MeetingRecordingCoordinator } private async Task ApplyMeetingMetadataAsync( + MeetingSessionArtifacts artifacts, MeetingNote meetingNote, MeetingMetadata metadata, MeetingAssistantOptions options, @@ -1104,10 +1110,39 @@ public sealed class MeetingRecordingCoordinator return; } - meetingNote.Frontmatter.Attendees = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken); + var canonicalized = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken); + meetingNote.Frontmatter.Attendees = await TransformAttendeesAsync( + artifacts, + canonicalized, + options, + cancellationToken); } } + private async Task> TransformAttendeesAsync( + MeetingSessionArtifacts artifacts, + IReadOnlyList attendees, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + var transformed = new List(); + foreach (var attendee in attendees) + { + var value = await TransformAttendeeAsync(artifacts, attendee, options, cancellationToken); + var storageValue = string.Equals(value, attendee, StringComparison.Ordinal) + ? attendee.Trim() + : NormalizeAttendeeName(value); + var normalized = NormalizeAttendeeName(storageValue); + if (!string.IsNullOrWhiteSpace(normalized) && + !transformed.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + transformed.Add(storageValue); + } + } + + return transformed; + } + private async Task> CanonicalizeAttendeesAsync( IReadOnlyList attendees, CancellationToken cancellationToken) @@ -1422,8 +1457,17 @@ public sealed class MeetingRecordingCoordinator continue; } + var transformedDisplayName = await TransformAttendeeAsync( + artifacts, + match.DisplayName.Trim(), + runOptions, + cancellationToken); + var displayName = string.Equals(transformedDisplayName, match.DisplayName.Trim(), StringComparison.Ordinal) + ? match.DisplayName.Trim() + : NormalizeAttendeeName(transformedDisplayName); var acceptedNames = match.AcceptedNames .Append(match.DisplayName) + .Append(displayName) .Select(NormalizeAttendeeName) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) @@ -1442,7 +1486,6 @@ public sealed class MeetingRecordingCoordinator continue; } - var displayName = match.DisplayName.Trim(); meetingNote.Frontmatter.Attendees.Add(displayName); existingNames.Add(NormalizeAttendeeName(displayName)); changed = true; @@ -1502,6 +1545,18 @@ public sealed class MeetingRecordingCoordinator return MeetingAttendeeNames.NormalizeDisplayName(attendee); } + private async Task TransformAttendeeAsync( + MeetingSessionArtifacts artifacts, + string attendee, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + return await meetingWorkflowEngine.TransformAttendeeAsync( + MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee), + options, + cancellationToken); + } + private static bool RemoveDuplicateAcceptedAliases( List attendees, string displayName, diff --git a/MeetingAssistant/Screenshots/MeetingScreenshotService.cs b/MeetingAssistant/Screenshots/MeetingScreenshotService.cs index 36bceef..8b9b4ea 100644 --- a/MeetingAssistant/Screenshots/MeetingScreenshotService.cs +++ b/MeetingAssistant/Screenshots/MeetingScreenshotService.cs @@ -5,6 +5,7 @@ using System.Runtime.InteropServices; using System.Text.RegularExpressions; using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; +using MeetingAssistant.Workflow; namespace MeetingAssistant.Screenshots; @@ -149,6 +150,7 @@ public sealed partial class MeetingScreenshotService : private readonly IMeetingArtifactStore artifactStore; private readonly IMeetingNoteStore meetingNoteStore; private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer; + private readonly IMeetingWorkflowEngine meetingWorkflowEngine; private readonly IScreenshotOcrClient ocrClient; private readonly ILogger logger; private readonly ConcurrentDictionary> pendingOcrByContext = new(StringComparer.OrdinalIgnoreCase); @@ -160,12 +162,14 @@ public sealed partial class MeetingScreenshotService : IMeetingNoteStore meetingNoteStore, ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer, IScreenshotOcrClient ocrClient, - ILogger logger) + ILogger logger, + IMeetingWorkflowEngine? meetingWorkflowEngine = null) { this.screenshotCapture = screenshotCapture; this.artifactStore = artifactStore; this.meetingNoteStore = meetingNoteStore; this.attendeeCanonicalizer = attendeeCanonicalizer; + this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance; this.ocrClient = ocrClient; this.logger = logger; } @@ -434,6 +438,7 @@ public sealed partial class MeetingScreenshotService : await AddOcrAttendeesAsync( artifacts, result.Attendees, + options, CancellationToken.None); } } @@ -477,6 +482,7 @@ public sealed partial class MeetingScreenshotService : private async Task AddOcrAttendeesAsync( MeetingSessionArtifacts artifacts, IReadOnlyList attendees, + MeetingAssistantOptions options, CancellationToken cancellationToken) { var additions = MeetingAttendeeNames.NormalizeDistinct(attendees); @@ -488,8 +494,13 @@ public sealed partial class MeetingScreenshotService : try { var meetingNote = await meetingNoteStore.ReadAsync(artifacts.MeetingNotePath, cancellationToken); + var transformedAdditions = await TransformAttendeesAsync( + artifacts, + additions, + options, + cancellationToken); var canonicalized = await attendeeCanonicalizer.CanonicalizeAsync( - meetingNote.Frontmatter.Attendees.Concat(additions).ToList(), + meetingNote.Frontmatter.Attendees.Concat(transformedAdditions).ToList(), cancellationToken); if (meetingNote.Frontmatter.Attendees.SequenceEqual(canonicalized, StringComparer.Ordinal)) { @@ -512,6 +523,30 @@ public sealed partial class MeetingScreenshotService : } } + private async Task> TransformAttendeesAsync( + MeetingSessionArtifacts artifacts, + IReadOnlyList attendees, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + var transformed = new List(); + foreach (var attendee in attendees) + { + var value = await meetingWorkflowEngine.TransformAttendeeAsync( + MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee), + options, + cancellationToken); + var normalized = MeetingAttendeeNames.NormalizeDisplayName(value); + if (!string.IsNullOrWhiteSpace(normalized) && + !transformed.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + transformed.Add(normalized); + } + } + + return transformed; + } + private async Task ReplaceOcrPlaceholderAsync( string assistantContextPath, string screenshotId, diff --git a/MeetingAssistant/Summary/MeetingSummaryTools.cs b/MeetingAssistant/Summary/MeetingSummaryTools.cs index 171b0de..a83c11b 100644 --- a/MeetingAssistant/Summary/MeetingSummaryTools.cs +++ b/MeetingAssistant/Summary/MeetingSummaryTools.cs @@ -1,6 +1,7 @@ using MeetingAssistant.MeetingNotes; using MeetingAssistant.Speakers; using MeetingAssistant.Transcription; +using MeetingAssistant.Workflow; using YamlDotNet.Serialization; namespace MeetingAssistant.Summary; @@ -13,6 +14,7 @@ public sealed class MeetingSummaryTools private readonly MeetingAssistantOptions options; private readonly IDictationWordStore? dictationWordStore; private readonly SummaryAgentWriteAudit? writeAudit; + private readonly IMeetingWorkflowEngine meetingWorkflowEngine; private readonly BoundMeetingProjectResolver projectResolver; public MeetingSummaryTools(MeetingSessionArtifacts artifacts) @@ -24,12 +26,14 @@ public sealed class MeetingSummaryTools MeetingSessionArtifacts artifacts, MeetingAssistantOptions options, IDictationWordStore? dictationWordStore = null, - SummaryAgentWriteAudit? writeAudit = null) + SummaryAgentWriteAudit? writeAudit = null, + IMeetingWorkflowEngine? meetingWorkflowEngine = null) { this.artifacts = artifacts; this.options = options; this.dictationWordStore = dictationWordStore; this.writeAudit = writeAudit; + this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance; projectResolver = new BoundMeetingProjectResolver(options); } @@ -98,7 +102,11 @@ public sealed class MeetingSummaryTools return "Refused: attendee must not be empty."; } - var displayName = attendee.Trim(); + var displayName = await meetingWorkflowEngine.TransformAttendeeAsync( + MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee.Trim()), + options, + CancellationToken.None); + displayName = displayName.Trim(); var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName); if (string.IsNullOrWhiteSpace(normalized)) { diff --git a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs index 562bc22..9785aaf 100644 --- a/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs +++ b/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs @@ -1,5 +1,6 @@ using MeetingAssistant.MeetingNotes; using MeetingAssistant.Transcription; +using MeetingAssistant.Workflow; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; @@ -17,6 +18,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline private readonly IMeetingSummaryFailureWriter failureWriter; private readonly IMeetingSummaryInstructionBuilder instructionBuilder; private readonly IDictationWordStore dictationWordStore; + private readonly IMeetingWorkflowEngine meetingWorkflowEngine; public OpenAiMeetingSummaryAgentPipeline( IOptions options, @@ -25,7 +27,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline IServiceProvider services, IMeetingSummaryFailureWriter failureWriter, IMeetingSummaryInstructionBuilder instructionBuilder, - IDictationWordStore dictationWordStore) + IDictationWordStore dictationWordStore, + IMeetingWorkflowEngine meetingWorkflowEngine) { this.options = options.Value; this.loggerFactory = loggerFactory; @@ -34,6 +37,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline this.failureWriter = failureWriter; this.instructionBuilder = instructionBuilder; this.dictationWordStore = dictationWordStore; + this.meetingWorkflowEngine = meetingWorkflowEngine; } public async Task RunAsync( @@ -51,7 +55,12 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline var agentOptions = options.Agent; var key = ResolveApiKey(agentOptions); var writeAudit = new SummaryAgentWriteAudit(artifacts); - var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit); + var meetingTools = new MeetingSummaryTools( + artifacts, + options, + dictationWordStore, + writeAudit, + meetingWorkflowEngine); var tools = CreateTools(meetingTools); var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken); using var compactionSummaryClient = new LiteLlmResponsesChatClient( diff --git a/MeetingAssistant/Workflow/MeetingWorkflowEngine.cs b/MeetingAssistant/Workflow/MeetingWorkflowEngine.cs index 811e742..fc24dd9 100644 --- a/MeetingAssistant/Workflow/MeetingWorkflowEngine.cs +++ b/MeetingAssistant/Workflow/MeetingWorkflowEngine.cs @@ -17,6 +17,14 @@ public interface IMeetingWorkflowEngine MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, CancellationToken cancellationToken); + + Task TransformAttendeeAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult(workflowEvent.AttendeeName ?? ""); + } } public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine @@ -38,6 +46,14 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine { return Task.FromResult(workflowEvent.TranscriptLineText ?? ""); } + + public Task TransformAttendeeAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + return Task.FromResult(workflowEvent.AttendeeName ?? ""); + } } public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine @@ -54,7 +70,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine "state.to", "speaker.name", MeetingWorkflowRuleSchema.PropertyTranscriptLine, - MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker + MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker, + MeetingWorkflowRuleSchema.PropertyAttendeeName ]; private readonly IMeetingWorkflowRulesProvider rulesProvider; @@ -98,6 +115,21 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine ?? ""; } + public async Task TransformAttendeeAsync( + MeetingWorkflowEvent workflowEvent, + MeetingAssistantOptions options, + CancellationToken cancellationToken) + { + if (workflowEvent.Type != MeetingWorkflowEventType.AttendeeAdded) + { + throw new ArgumentException("Workflow event must be an attendee added event.", nameof(workflowEvent)); + } + + return await RunCoreAsync(workflowEvent, options, cancellationToken) + ?? workflowEvent.AttendeeName + ?? ""; + } + private async Task RunCoreAsync( MeetingWorkflowEvent workflowEvent, MeetingAssistantOptions options, @@ -107,7 +139,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine var context = new MeetingWorkflowExecutionContext(workflowEvent); if (rules.Count == 0) { - return context.TranscriptLine; + return context.ResultValue; } var meeting = await meetingNoteStore.ReadAsync( @@ -134,10 +166,18 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine var model = CreateTemplateModel(meeting, context); foreach (var step in rule.Steps) { + if (workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded && + !IsAttendeeTransformStep(step)) + { + throw new InvalidOperationException( + "attendee_added workflow rules can only transform attendee.name with set_property."); + } + var stepChanged = await ApplyStepAsync( step, meeting, context, + options, model, cancellationToken); ruleNoteChanged |= stepChanged; @@ -176,7 +216,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine cancellationToken); } - return context.TranscriptLine; + return context.ResultValue; } private static bool MatchesTrigger( @@ -229,9 +269,41 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine StringComparison.OrdinalIgnoreCase)); } + if (trigger.AttendeeAdded is not null) + { + return workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded && + MatchesAttendeeAddedTrigger(trigger.AttendeeAdded, workflowEvent.AttendeeName); + } + return false; } + private static bool MatchesAttendeeAddedTrigger( + MeetingWorkflowAttendeeAddedTrigger trigger, + string? attendeeName) + { + var attendee = attendeeName ?? ""; + if (!string.IsNullOrWhiteSpace(trigger.EqualsValue) && + !string.Equals(attendee, trigger.EqualsValue.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(trigger.Contains) && + !attendee.Contains(trigger.Contains.Trim(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(trigger.Regex) && + !Regex.IsMatch(attendee, trigger.Regex, RegexOptions.IgnoreCase)) + { + return false; + } + + return true; + } + private bool EvaluateConditions( IReadOnlyList conditions, MeetingNote meeting, @@ -298,6 +370,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine MeetingWorkflowStep step, MeetingNote meeting, MeetingWorkflowExecutionContext context, + MeetingAssistantOptions options, MeetingWorkflowTemplateModel model, CancellationToken cancellationToken) { @@ -305,7 +378,11 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine switch (step.Uses.Trim().ToLowerInvariant()) { case MeetingWorkflowRuleSchema.StepAddAttendee: - return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName); + var attendee = await TransformAttendeeAsync( + MeetingWorkflowEvent.AttendeeAdded(context.Event.Artifacts, value), + options, + cancellationToken); + return AddUnique(meeting.Frontmatter.Attendees, attendee, MeetingAttendeeNames.NormalizeDisplayName); case MeetingWorkflowRuleSchema.StepRemoveAttendee: return RemoveValue(meeting.Frontmatter.Attendees, value); case MeetingWorkflowRuleSchema.StepAddProject: @@ -319,6 +396,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine case MeetingWorkflowRuleSchema.StepSetProperty when IsTranscriptLineProperty(step.Property ?? step.Name): SetTranscriptLine(context, value); return false; + case MeetingWorkflowRuleSchema.StepSetProperty when IsAttendeeNameProperty(step.Property ?? step.Name): + SetAttendeeName(context, value); + return false; case MeetingWorkflowRuleSchema.StepSetProperty: return SetProperty(meeting, step.Property ?? step.Name, value); default: @@ -370,11 +450,39 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine context.TranscriptLine = value; } + private static void SetAttendeeName( + MeetingWorkflowExecutionContext context, + string value) + { + if (context.Event.Type != MeetingWorkflowEventType.AttendeeAdded) + { + throw new InvalidOperationException("The attendee.name property can only be set during attendee_added events."); + } + + if (string.Equals(context.AttendeeName, value, StringComparison.Ordinal)) + { + return; + } + + context.AttendeeName = value; + } + private static bool IsTranscriptLineProperty(string? property) { return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine); } + private static bool IsAttendeeNameProperty(string? property) + { + return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName); + } + + private static bool IsAttendeeTransformStep(MeetingWorkflowStep step) + { + return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) && + IsAttendeeNameProperty(step.Property ?? step.Name); + } + private static bool AddUnique( List values, string value, @@ -456,7 +564,8 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine ["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "", ["speaker.name"] = workflowEvent.SpeakerName, [MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "", - [MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? "" + [MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? "", + [MeetingWorkflowRuleSchema.PropertyAttendeeName] = context.AttendeeName ?? "" }; } @@ -480,7 +589,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine : new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName), new MeetingWorkflowTranscriptModel( context.TranscriptLine ?? "", - workflowEvent.SpeakerName ?? "")); + workflowEvent.SpeakerName ?? ""), + string.IsNullOrWhiteSpace(context.AttendeeName) + ? null + : new MeetingWorkflowAttendeeModel(context.AttendeeName)); } private sealed class MeetingWorkflowExecutionContext @@ -489,10 +601,23 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine { Event = workflowEvent; TranscriptLine = workflowEvent.TranscriptLineText; + AttendeeName = workflowEvent.AttendeeName; } public MeetingWorkflowEvent Event { get; } public string? TranscriptLine { get; set; } + + public string? AttendeeName { get; set; } + + public string? ResultValue + { + get + { + return Event.Type == MeetingWorkflowEventType.AttendeeAdded + ? AttendeeName + : TranscriptLine; + } + } } } diff --git a/MeetingAssistant/Workflow/MeetingWorkflowEvent.cs b/MeetingAssistant/Workflow/MeetingWorkflowEvent.cs index 523fbd9..4cf5c4a 100644 --- a/MeetingAssistant/Workflow/MeetingWorkflowEvent.cs +++ b/MeetingAssistant/Workflow/MeetingWorkflowEvent.cs @@ -7,7 +7,8 @@ public enum MeetingWorkflowEventType Created, StateTransition, SpeakerIdentified, - TranscriptLine + TranscriptLine, + AttendeeAdded } public sealed record MeetingWorkflowEvent( @@ -16,7 +17,8 @@ public sealed record MeetingWorkflowEvent( AssistantContextState? FromState = null, AssistantContextState? ToState = null, string? SpeakerName = null, - string? TranscriptLineText = null) + string? TranscriptLineText = null, + string? AttendeeName = null) { public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts) { @@ -49,4 +51,14 @@ public sealed record MeetingWorkflowEvent( SpeakerName: speakerName, TranscriptLineText: line); } + + public static MeetingWorkflowEvent AttendeeAdded( + MeetingSessionArtifacts artifacts, + string attendeeName) + { + return new MeetingWorkflowEvent( + MeetingWorkflowEventType.AttendeeAdded, + artifacts, + AttendeeName: attendeeName); + } } diff --git a/MeetingAssistant/Workflow/MeetingWorkflowModels.cs b/MeetingAssistant/Workflow/MeetingWorkflowModels.cs index c870c86..83c7ec2 100644 --- a/MeetingAssistant/Workflow/MeetingWorkflowModels.cs +++ b/MeetingAssistant/Workflow/MeetingWorkflowModels.cs @@ -37,6 +37,9 @@ public sealed class MeetingWorkflowTrigger [YamlMember(Alias = "transcript_line")] public MeetingWorkflowTranscriptLineTrigger? TranscriptLine { get; set; } + + [YamlMember(Alias = "attendee_added")] + public MeetingWorkflowAttendeeAddedTrigger? AttendeeAdded { get; set; } } public sealed class MeetingWorkflowStateTransitionTrigger @@ -60,6 +63,18 @@ public sealed class MeetingWorkflowTranscriptLineTrigger public string? Speaker { get; set; } } +public sealed class MeetingWorkflowAttendeeAddedTrigger +{ + [YamlMember(Alias = "equals")] + public string? EqualsValue { get; set; } + + [YamlMember(Alias = "contains")] + public string? Contains { get; set; } + + [YamlMember(Alias = "regex")] + public string? Regex { get; set; } +} + public sealed class MeetingWorkflowCondition { [YamlMember(Alias = "condition")] @@ -94,7 +109,8 @@ public sealed record MeetingWorkflowTemplateModel( MeetingWorkflowMeetingModel Meeting, MeetingWorkflowEventModel Event, MeetingWorkflowSpeakerModel? Speaker, - MeetingWorkflowTranscriptModel? Transcript); + MeetingWorkflowTranscriptModel? Transcript, + MeetingWorkflowAttendeeModel? Attendee); public sealed record MeetingWorkflowMeetingModel( string Title, @@ -111,6 +127,8 @@ public sealed record MeetingWorkflowSpeakerModel(string Name); public sealed record MeetingWorkflowTranscriptModel(string Line, string Speaker); +public sealed record MeetingWorkflowAttendeeModel(string Name); + internal static class MeetingWorkflowStateNames { public static string ToRuleName(AssistantContextState state) diff --git a/MeetingAssistant/Workflow/MeetingWorkflowRuleSchema.cs b/MeetingAssistant/Workflow/MeetingWorkflowRuleSchema.cs index 2fdc0ec..cd7d5af 100644 --- a/MeetingAssistant/Workflow/MeetingWorkflowRuleSchema.cs +++ b/MeetingAssistant/Workflow/MeetingWorkflowRuleSchema.cs @@ -12,13 +12,15 @@ internal static class MeetingWorkflowRuleSchema public const string PropertyMeetingTitle = "meeting.title"; public const string PropertyTranscriptLine = "transcript.line"; public const string ParameterTranscriptSpeaker = "transcript.speaker"; + public const string PropertyAttendeeName = "attendee.name"; public static readonly string[] SupportedTriggerKeys = [ "created", "state_transition", "speaker_identified", - "transcript_line" + "transcript_line", + "attendee_added" ]; public static readonly string[] SupportedConditionKeys = @@ -42,7 +44,8 @@ internal static class MeetingWorkflowRuleSchema [ PropertyTitle, PropertyMeetingTitle, - PropertyTranscriptLine + PropertyTranscriptLine, + PropertyAttendeeName ]; public static bool IsStep(string? value, string step) diff --git a/MeetingAssistant/Workflow/MeetingWorkflowRulesValidator.cs b/MeetingAssistant/Workflow/MeetingWorkflowRulesValidator.cs index d02474a..5edc348 100644 --- a/MeetingAssistant/Workflow/MeetingWorkflowRulesValidator.cs +++ b/MeetingAssistant/Workflow/MeetingWorkflowRulesValidator.cs @@ -59,7 +59,8 @@ internal static class MeetingWorkflowRulesValidator trigger.Created is not null, trigger.StateTransition is not null, trigger.SpeakerIdentified is not null, - trigger.TranscriptLine is not null); + trigger.TranscriptLine is not null, + trigger.AttendeeAdded is not null); if (configuredCount == 0) { errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedTriggerKeys)}."); @@ -137,6 +138,12 @@ internal static class MeetingWorkflowRulesValidator return; } + if (HasAttendeeAddedTrigger(rule) && + !IsAttendeeNameSetPropertyStep(step)) + { + errors.Add($"{stepPath} on attendee_added rules can only transform the attendee with 'set_property' property 'attendee.name'."); + } + if (MeetingWorkflowRuleSchema.IsStep(uses, MeetingWorkflowRuleSchema.StepSetProperty)) { var property = step.Property ?? step.Name; @@ -153,6 +160,11 @@ internal static class MeetingWorkflowRulesValidator { errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger."); } + else if (MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName) && + !HasAttendeeAddedTrigger(rule)) + { + errors.Add($"{stepPath} can set 'attendee.name' only on rules with an attendee_added trigger."); + } } if (step.Value is { } value && @@ -181,6 +193,17 @@ internal static class MeetingWorkflowRulesValidator return rule.On.Any(static trigger => trigger.TranscriptLine is not null); } + private static bool HasAttendeeAddedTrigger(MeetingWorkflowRule rule) + { + return rule.On.Any(static trigger => trigger.AttendeeAdded is not null); + } + + private static bool IsAttendeeNameSetPropertyStep(MeetingWorkflowStep step) + { + return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) && + MeetingWorkflowRuleSchema.IsProperty(step.Property ?? step.Name, MeetingWorkflowRuleSchema.PropertyAttendeeName); + } + private static int CountConfigured(params bool[] values) { return values.Count(static value => value); @@ -206,7 +229,8 @@ internal static class MeetingWorkflowRulesValidator new MeetingWorkflowSpeakerModel("Ada Lovelace"), new MeetingWorkflowTranscriptModel( "[00:00:01] Ada Lovelace: Validation line.", - "Ada Lovelace")); + "Ada Lovelace"), + new MeetingWorkflowAttendeeModel("Ada Lovelace")); } private static string FormatRazorValidationMessage(Exception exception) diff --git a/README.md b/README.md index 334c7b7..a9d245a 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen ## Workflow Rules And Agents -Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, and transcript-line writes. They can add/remove attendees, set supported properties, add context, add projects, and rewrite a transcript line before persistence. +Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, transcript-line writes, and added attendees. They can add/remove attendees, set supported properties, add context, add projects, rewrite a transcript line, and transform an attendee name before it is stored. The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools. diff --git a/docs/meeting-workflow-engine.md b/docs/meeting-workflow-engine.md index 6a7804e..6c18886 100644 --- a/docs/meeting-workflow-engine.md +++ b/docs/meeting-workflow-engine.md @@ -71,6 +71,12 @@ The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow even - `state_transition`: after the assistant context state moves forward. - `speaker_identified`: when live or final speaker identification reports a display name. - `transcript_line`: after a formatted live transcript line is durably appended, before any changed line is rewritten in place, and before lines are used in full transcript rewrites. +- `attendee_added`: before a newly added attendee is stored in the meeting note. + +`attendee_added` is emitted only by Meeting Assistant code paths that intentionally add an attendee +through the workflow engine, notification actions, OCR/screenshot processing, summarizer tools, or +other attendee-add operations. Direct edits to a meeting note file, including direct artifact/frontmatter +repair writes by an agent, are left verbatim and do not trigger attendee automation. When a recording is started by accepting a calendar notification, the cached appointment metadata is applied before the `created` event. Rules still receive the normal `created` event and the normal `collecting metadata` to `transcribing` state transition, but they see the accepted appointment title, attendees, agenda, and scheduled end instead of a generated placeholder or a separate Outlook current-meeting lookup result. @@ -162,6 +168,22 @@ on: speaker: Guest-1 ``` +### `attendee_added` + +Runs before a newly added attendee is stored. `equals`, `contains`, and `regex` filters are optional; omitted filters match any attendee. `equals` and `contains` are case-insensitive. + +```yaml +on: + - attendee_added: + contains: Contoso +``` + +```yaml +on: + - attendee_added: + regex: '@contoso\.com>$' +``` + ## Conditions Conditions use NCalc expressions. Dotted workflow variables may be written directly; the engine rewrites them into NCalc parameters internally. @@ -184,6 +206,7 @@ Available condition variables: - `speaker.name` - `transcript.line` - `transcript.speaker` +- `attendee.name` Available helper functions: @@ -217,6 +240,7 @@ Step `value` fields can be plain strings or Razor templates. Razor templates rec - `Model.Speaker.Name` - `Model.Transcript.Line` - `Model.Transcript.Speaker` +- `Model.Attendee.Name` Example: @@ -259,7 +283,11 @@ steps: ### `set_property` -Sets a supported property. Supported meeting properties are `title` and `meeting.title`. During `transcript_line` events, `transcript.line` is also supported. For live transcription, changing `transcript.line` rewrites the referenced formatted line after the workflow task completes. +Sets a supported property. Supported meeting properties are `title` and `meeting.title`. During `transcript_line` events, `transcript.line` is also supported. During `attendee_added` events, `attendee.name` is supported. For live transcription, changing `transcript.line` rewrites the referenced formatted line after the workflow task completes. For attendee additions, changing `attendee.name` stores the transformed attendee instead of the original added value. + +Rules triggered by `attendee_added` are transformation-only: their steps must use `set_property` +with `property: attendee.name`. They can still use trigger filters, conditions, and Razor templates +to decide and compute the transformed value, but they cannot perform note/context side effects. ```yaml steps: @@ -275,6 +303,13 @@ steps: value: '@Model.Transcript.Line.Replace("*****", "[redacted]")' ``` +```yaml +steps: + - uses: set_property + property: attendee.name + value: '@Model.Attendee.Name.Replace(" (Contoso)", "")' +``` + ### `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. @@ -370,6 +405,20 @@ rules: value: '@Model.Transcript.Line.Replace("*****", "[redacted]")' ``` +Clean attendee names as they are added: + +```yaml +rules: +- name: clean-contoso-attendees + on: + - attendee_added: + contains: Contoso + steps: + - uses: set_property + property: attendee.name + value: '@Model.Attendee.Name.Replace(" (Contoso)", "")' +``` + ## Implementation Notes Core files: diff --git a/openspec/changes/archive/2026-07-01-transform-added-attendees/.openspec.yaml b/openspec/changes/archive/2026-07-01-transform-added-attendees/.openspec.yaml new file mode 100644 index 0000000..e7cc357 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-transform-added-attendees/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-01 diff --git a/openspec/changes/archive/2026-07-01-transform-added-attendees/design.md b/openspec/changes/archive/2026-07-01-transform-added-attendees/design.md new file mode 100644 index 0000000..993a118 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-transform-added-attendees/design.md @@ -0,0 +1,30 @@ +# Design + +## Event Model + +Add a workflow event type, `attendee_added`, that carries the attendee string being added. The event is used as a transformation hook before a caller stores the attendee in the meeting note. Rules can match the event with `equals`, `contains`, or `regex` trigger filters over the current attendee value. + +## Transformation Contract + +The workflow engine exposes `TransformAttendeeAsync`, mirroring `TransformTranscriptLineAsync`. During `attendee_added` events: + +- conditions can read `attendee.name`, +- Razor templates can read `Model.Attendee.Name`, +- `set_property attendee.name` mutates the attendee value returned to the caller. + +Other workflow steps keep their existing semantics, but the intended transformation path is `set_property attendee.name`. +Rules triggered by `attendee_added` are limited to that transformation path so the value transform does not hide unrelated note or context side effects. + +Direct meeting note file edits remain outside the workflow event model. If a user or agent writes a meeting note file/frontmatter directly, Meeting Assistant preserves that content verbatim and does not emit `attendee_added`. + +## Call Sites + +Callers transform attendee candidates before writing them: + +- metadata and accepted prompt metadata after canonicalization, +- workflow `add_attendee` steps before de-duplication, +- screenshot OCR candidates before canonicalization and merge, +- summary-agent `add_attendee` tool calls before duplicate checks and note writes, +- speaker identity attendee additions before adding matched names. + +Unchanged metadata attendees preserve the existing canonicalizer output, including email display strings. diff --git a/openspec/changes/archive/2026-07-01-transform-added-attendees/proposal.md b/openspec/changes/archive/2026-07-01-transform-added-attendees/proposal.md new file mode 100644 index 0000000..cdf9b02 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-transform-added-attendees/proposal.md @@ -0,0 +1,17 @@ +## Why + +Attendees enter Meeting Assistant from several paths: calendar metadata, accepted recording prompts, workflow actions, screenshot OCR, speaker identity updates, and summarizer add-attendee tools. Today those paths can normalize display names, but they cannot apply local transformation rules such as trimming company suffixes, replacing aliases, or cleaning attendee strings consistently. + +## What Changes + +- Add an `attendee_added` workflow trigger that runs whenever Meeting Assistant adds an attendee to a meeting note. +- Allow `attendee_added` triggers to filter the added attendee with full equality, substring, or regex matching. +- Expose `attendee.name` to workflow conditions and Razor templates. +- Allow `set_property` to update `attendee.name` during `attendee_added` events, mirroring transcript-line transformation. +- Apply attendee transformations to attendees added by metadata import, prompted-start metadata, workflow `add_attendee`, screenshot OCR, and summary/workflow editor agent tools. + +## Impact + +- Workflow engine, rules schema validation, and workflow documentation. +- Attendee write paths in recording metadata, screenshot OCR, summary tools, and workflow actions. +- Behavior tests for workflow transformation and representative attendee-add sources. diff --git a/openspec/changes/archive/2026-07-01-transform-added-attendees/specs/meeting-session/spec.md b/openspec/changes/archive/2026-07-01-transform-added-attendees/specs/meeting-session/spec.md new file mode 100644 index 0000000..17d4896 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-transform-added-attendees/specs/meeting-session/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Meeting automation rules support lifecycle triggers +Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, `speaker_identified`, `transcript_line`, and `attendee_added`. + +An `attendee_added` trigger MAY filter by `equals`, `contains`, or `regex` against the attendee value being added. `equals` and `contains` filters SHALL match case-insensitively. + +Meeting Assistant SHALL apply attendee-added transformations before storing attendees added from meeting metadata, accepted recording-prompt metadata, workflow `add_attendee` steps, screenshot OCR, speaker identity updates, and summarizer add-attendee tools. + +#### Scenario: Attendee added rule filters and transforms a metadata attendee +- **GIVEN** a configured rule that triggers on added attendees containing `@contoso.com` +- **AND** the rule sets `attendee.name` to a display name derived from the added attendee +- **WHEN** Meeting Assistant adds attendee `Ada Lovelace ` from meeting metadata +- **THEN** the stored meeting attendee is the transformed attendee name +- **AND** the original attendee string is not stored + +### Requirement: Meeting automation rules support conditions and steps +Meeting Assistant SHALL expose the added attendee name to `attendee_added` rule conditions and Razor step templates as `attendee.name`. + +The `set_property` step SHALL support setting `attendee.name` during `attendee_added` events. + +Rules with an `attendee_added` trigger SHALL be limited to transforming `attendee.name` with `set_property`. + +Direct meeting note file writes SHALL NOT emit `attendee_added` or transform attendee values. + +#### Scenario: Attendee added rules can use regex and Razor +- **GIVEN** a configured `attendee_added` rule with a regex filter over the added attendee +- **WHEN** Meeting Assistant adds an attendee matching the regex +- **THEN** the rule can set `attendee.name` with a Razor template using `Model.Attendee.Name` diff --git a/openspec/changes/archive/2026-07-01-transform-added-attendees/tasks.md b/openspec/changes/archive/2026-07-01-transform-added-attendees/tasks.md new file mode 100644 index 0000000..2b6e765 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-transform-added-attendees/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +- [x] Add OpenSpec scenarios for attendee-added trigger filtering and transformation. +- [x] Add a failing workflow-engine behavior test for transforming an added attendee through `attendee_added`. +- [x] Implement attendee-added workflow event, trigger filters, `attendee.name` condition/template value, and `set_property attendee.name`. +- [x] Apply attendee transformations from metadata/prompted starts, workflow `add_attendee`, screenshot OCR, and agent attendee tools. +- [x] Update workflow engine documentation. +- [x] Run focused tests plus `openspec validate transform-added-attendees --strict`. diff --git a/openspec/specs/meeting-session/spec.md b/openspec/specs/meeting-session/spec.md index 9320605..a6d194f 100644 --- a/openspec/specs/meeting-session/spec.md +++ b/openspec/specs/meeting-session/spec.md @@ -221,105 +221,32 @@ 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`, `speaker_identified`, and `transcript_line`. +Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, `speaker_identified`, `transcript_line`, and `attendee_added`. -A `state_transition` trigger MAY filter by `from`, `to`, or both state values. +An `attendee_added` trigger MAY filter by `equals`, `contains`, or `regex` against the attendee value being added. `equals` and `contains` filters SHALL match case-insensitively. -A `speaker_identified` trigger MAY filter by speaker name. +Meeting Assistant SHALL apply attendee-added transformations before storing attendees added from meeting metadata, accepted recording-prompt metadata, workflow `add_attendee` steps, screenshot OCR, speaker identity updates, and summarizer add-attendee tools. -A `transcript_line` trigger MAY filter by speaker name. - -Workflow rule execution SHALL log each triggered rule with its rule name and event type. - -Workflow rule execution SHALL log rule failures with the rule name and event type. - -#### 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 after durable append -- **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** Meeting Assistant first appends the original formatted line to the transcript file -- **AND** rewrites that written line to contain `[redacted]` -- **AND** the final written transcript line does not contain `*****` - -#### Scenario: Transcript line workflow failure keeps transcript writing -- **GIVEN** a configured transcript line workflow rule fails while processing a transcript line -- **WHEN** Meeting Assistant receives that live transcript segment -- **THEN** Meeting Assistant keeps the original formatted line in the transcript file -- **AND** logs the workflow rule failure -- **AND** continues processing later transcript segments +#### Scenario: Attendee added rule filters and transforms a metadata attendee +- **GIVEN** a configured rule that triggers on added attendees containing `@contoso.com` +- **AND** the rule sets `attendee.name` to a display name derived from the added attendee +- **WHEN** Meeting Assistant adds attendee `Ada Lovelace ` from meeting metadata +- **THEN** the stored meeting attendee is the transformed attendee name +- **AND** the original attendee string is not stored ### Requirement: Meeting automation rules support conditions and steps -Meeting Assistant SHALL support rule conditions using an expression engine. +Meeting Assistant SHALL expose the added attendee name to `attendee_added` rule conditions and Razor step templates as `attendee.name`. -Rules SHALL support nested `and`, `or`, and `not` condition groups. +The `set_property` step SHALL support setting `attendee.name` during `attendee_added` events. -Step values SHALL support Razor syntax against the current meeting event model. +Rules with an `attendee_added` trigger SHALL be limited to transforming `attendee.name` with `set_property`. -Rendered step values SHALL be treated as plain UTF-8 text for markdown artifacts and SHALL NOT persist Razor HTML entity encoding. +Direct meeting note file writes SHALL NOT emit `attendee_added` or transform attendee values. -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: Razor-rendered transcript line preserves UTF-8 text -- **GIVEN** a configured `transcript_line` rule uses Razor to replace part of a transcript line containing `heißt`, `Schimpfwörter`, and `nächstes` -- **WHEN** the rule changes `transcript.line` -- **THEN** the resulting transcript line contains the original UTF-8 characters -- **AND** does not contain HTML entities such as `ß`, `ö`, or `ä` - -#### 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** Meeting Assistant applies the rule steps after the original formatted line is durably appended -- **AND** rewrites the written line if a rule changes `transcript.line` +#### Scenario: Attendee added rules can use regex and Razor +- **GIVEN** a configured `attendee_added` rule with a regex filter over the added attendee +- **WHEN** Meeting Assistant adds an attendee matching the regex +- **THEN** the rule can set `attendee.name` with a Razor template using `Model.Attendee.Name` ### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant Meeting Assistant SHALL expose an `Open agent` item from the tray icon menu.