Public Access
Add attendee transformation workflows
This commit is contained in:
@@ -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<MeetingScreenshotService>.Instance);
|
||||
NullLogger<MeetingScreenshotService>.Instance,
|
||||
meetingWorkflowEngine);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<MeetingRecordingCoordinator>.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()
|
||||
{
|
||||
|
||||
@@ -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<string> AttendeeRequests { get; } = [];
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
|
||||
public Task<string> TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AttendeeRequests.Add(workflowEvent.AttendeeName ?? "");
|
||||
return Task.FromResult(string.Equals(workflowEvent.AttendeeName, source, StringComparison.OrdinalIgnoreCase)
|
||||
? replacement
|
||||
: workflowEvent.AttendeeName ?? "");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user