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),
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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<List<string>> TransformAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var transformed = new List<string>();
|
||||
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<List<string>> CanonicalizeAttendeesAsync(
|
||||
IReadOnlyList<string> 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<string> TransformAttendeeAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string attendee,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await meetingWorkflowEngine.TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee),
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static bool RemoveDuplicateAcceptedAliases(
|
||||
List<string> attendees,
|
||||
string displayName,
|
||||
|
||||
@@ -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<MeetingScreenshotService> logger;
|
||||
private readonly ConcurrentDictionary<string, List<PendingOcrTask>> pendingOcrByContext = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -160,12 +162,14 @@ public sealed partial class MeetingScreenshotService :
|
||||
IMeetingNoteStore meetingNoteStore,
|
||||
ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer,
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ILogger<MeetingScreenshotService> logger)
|
||||
ILogger<MeetingScreenshotService> 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<string> 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<IReadOnlyList<string>> TransformAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var transformed = new List<string>();
|
||||
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<bool> ReplaceOcrPlaceholderAsync(
|
||||
string assistantContextPath,
|
||||
string screenshotId,
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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<MeetingAssistantOptions> 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<MeetingSummaryRunResult> 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(
|
||||
|
||||
@@ -17,6 +17,14 @@ public interface IMeetingWorkflowEngine
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<string> 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<string> 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<string> 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<string?> 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<MeetingWorkflowCondition> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-01
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+29
@@ -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 <ada@contoso.com>` 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`
|
||||
@@ -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`.
|
||||
@@ -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 <ada@contoso.com>` 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.
|
||||
|
||||
Reference in New Issue
Block a user