Add meeting workflow automation

This commit is contained in:
2026-05-27 12:55:18 +02:00
parent e85274829a
commit b114071957
29 changed files with 1703 additions and 12 deletions
+2
View File
@@ -48,6 +48,8 @@ MeetingAssistant/appsettings.Local.json
MeetingAssistant/appsettings.*.local.json MeetingAssistant/appsettings.*.local.json
MeetingAssistant/**/appsettings.Local.json MeetingAssistant/**/appsettings.Local.json
MeetingAssistant/**/appsettings.*.local.json MeetingAssistant/**/appsettings.*.local.json
meeting-rules.local.yaml
MeetingAssistant/meeting-rules.local.yaml
.meeting-assistant/ .meeting-assistant/
tmp/ tmp/
recordings/ recordings/
+12
View File
@@ -80,6 +80,18 @@ Keep source and deployment ownership separate:
- `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes. - `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes.
- A future deployment repository, if created, should own Docker Compose, Traefik labels, secrets/variables, networks, and live image tag selection. - A future deployment repository, if created, should own Docker Compose, Traefik labels, secrets/variables, networks, and live image tag selection.
## Workflow Engine Documentation
When changing the meeting workflow engine, update `docs/meeting-workflow-engine.md` in the same change. This includes changes to:
- YAML rule shape or configuration
- supported triggers, states, conditions, expression variables, helper functions, or Razor template model
- supported steps or step semantics
- workflow event emission points in the recording lifecycle
- safety behavior for missing files, invalid rules, persistence, or local ignored rule files
Keep `README.md` as the short operational overview and keep `docs/meeting-workflow-engine.md` as the detailed reference.
Before finishing code changes, run the narrowest useful test command first. For broader changes, run: Before finishing code changes, run the narrowest useful test command first. For broader changes, run:
```powershell ```powershell
@@ -54,6 +54,8 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("title: Meeting", summary); Assert.Contains("title: Meeting", summary);
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary); Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary); Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
Assert.Contains("attendees:", summary);
Assert.Contains("- Ada", summary);
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary); Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary); Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary); Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
@@ -61,6 +63,49 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("# Summary\n\n- Done.", summary); Assert.Contains("# Summary\n\n- Done.", summary);
} }
[Fact]
public async Task WriteSummaryPreservesExistingSummaryAttendees()
{
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)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
"""
---
title: Meeting
attendees:
- Ada
- Grace
---
""");
await File.WriteAllTextAsync(
artifacts.SummaryPath,
"""
---
title: Existing
attendees:
- Preserved
---
Old summary.
""");
var tools = new MeetingSummaryTools(artifacts);
await tools.WriteSummary("# Summary");
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("attendees:", summary);
Assert.Contains("- Preserved", summary);
Assert.DoesNotContain("- Ada", summary);
Assert.DoesNotContain("- Grace", summary);
}
[Fact] [Fact]
public async Task ReadToolsSupportClampedLineRanges() public async Task ReadToolsSupportClampedLineRanges()
{ {
@@ -0,0 +1,246 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Tests;
public sealed class MeetingWorkflowEngineTests
{
[Fact]
public async Task CreatedRuleAddsDefaultAttendeeOnceWhenMeetingHasNoAttendees()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: add-me
on:
- created: {}
if:
- condition: meeting.attendees.count = 0
steps:
- uses: add_attendee
value: Manuel
""");
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.Created(fixture.Artifacts),
fixture.Options,
CancellationToken.None);
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.Created(fixture.Artifacts),
fixture.Options,
CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Manuel"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task StateTransitionRuleCanCleanTitleAndAddProjectWithNestedConditions()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: clean-teams-title
on:
- state_transition:
from: collecting metadata
to: transcribing
if:
- and:
- condition: contains(meeting.title, '[Teams]')
- not:
condition: contains(meeting.projects, 'Architecture')
steps:
- uses: set_property
property: title
value: '@Model.Meeting.Title.Replace("[Teams]", "").Trim()'
- uses: add_project
value: Architecture
""",
title: "[Teams] Architecture Sync");
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.StateTransition(
fixture.Artifacts,
AssistantContextState.CollectingMetadata,
AssistantContextState.Transcribing),
fixture.Options,
CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal("Architecture Sync", meeting.Frontmatter.Title);
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
}
[Fact]
public async Task SpeakerIdentifiedRuleFiltersByNameAndWritesRazorContext()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: ada-context
on:
- speaker_identified:
name: Ada
if:
- or:
- condition: contains(meeting.title, 'Architecture')
- condition: meeting.attendees.count > 3
steps:
- uses: add_context
value: 'Speaker @Model.Speaker.Name was identified in @Model.Meeting.Title.'
""",
title: "Architecture Sync");
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Grace"),
fixture.Options,
CancellationToken.None);
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
fixture.Options,
CancellationToken.None);
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
Assert.DoesNotContain("Grace", context);
Assert.Contains("Speaker Ada was identified in Architecture Sync.", context);
}
[Fact]
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: remove-placeholder
on:
- created: {}
if:
- and:
- condition: contains(meeting.attendees, 'Placeholder')
- not:
condition: contains(meeting.title, 'Keep Placeholder')
steps:
- uses: remove_attendee
value: Placeholder
""",
attendees: ["Placeholder", "Ada"]);
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.Created(fixture.Artifacts),
fixture.Options,
CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Equal(["Ada"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task MissingRulesFileDoesNothing()
{
var fixture = await WorkflowFixture.CreateAsync("", rulesPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.yaml"));
await fixture.Engine.RunAsync(
MeetingWorkflowEvent.Created(fixture.Artifacts),
fixture.Options,
CancellationToken.None);
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
Assert.Empty(meeting.Frontmatter.Attendees);
}
private sealed class WorkflowFixture
{
private WorkflowFixture(
MeetingAssistantOptions options,
MarkdownMeetingNoteStore noteStore,
MeetingSessionArtifacts artifacts,
MeetingWorkflowEngine engine)
{
Options = options;
NoteStore = noteStore;
Artifacts = artifacts;
Engine = engine;
}
public MeetingAssistantOptions Options { get; }
public MarkdownMeetingNoteStore NoteStore { get; }
public MeetingSessionArtifacts Artifacts { get; }
public MeetingWorkflowEngine Engine { get; }
public static async Task<WorkflowFixture> CreateAsync(
string rulesYaml,
string title = "Planning Sync",
IReadOnlyList<string>? attendees = null,
string? rulesPath = null)
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
rulesPath ??= Path.Combine(root, "rules.yaml");
if (!string.IsNullOrEmpty(rulesYaml))
{
await File.WriteAllTextAsync(rulesPath, rulesYaml);
}
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions
{
BaseFolder = root,
MeetingNotesFolder = "Notes",
TranscriptsFolder = "Transcripts",
AssistantContextFolder = "Context",
SummariesFolder = "Summaries",
ProjectsFolder = "Projects"
},
Automation = new AutomationOptions
{
RulesPath = rulesPath
}
};
var noteStore = new MarkdownMeetingNoteStore(
Microsoft.Extensions.Options.Options.Create(options),
NullLogger<MarkdownMeetingNoteStore>.Instance);
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.Instance);
var artifacts = new MeetingSessionArtifacts(
Path.Combine(root, "Notes", "meeting.md"),
Path.Combine(root, "Transcripts", "transcript.md"),
Path.Combine(root, "Context", "context.md"),
Path.Combine(root, "Summaries", "summary.md"));
var meeting = await noteStore.SaveAsync(
MeetingNoteTemplate.Create(
title,
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
attendees: attendees ?? [],
transcriptPath: artifacts.TranscriptPath,
assistantContextPath: artifacts.AssistantContextPath,
summaryPath: artifacts.SummaryPath) with
{
Path = artifacts.MeetingNotePath
},
options,
CancellationToken.None);
await artifactStore.CreateAssistantContextAsync(
artifacts,
meeting,
"",
null,
CancellationToken.None);
var engine = new MeetingWorkflowEngine(
new FileMeetingWorkflowRulesProvider(
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
NullLogger<MeetingWorkflowEngine>.Instance);
return new WorkflowFixture(options, noteStore, artifacts, engine);
}
}
}
@@ -4,6 +4,7 @@ using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription; using MeetingAssistant.Transcription;
using MeetingAssistant.MeetingNotes; using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary; using MeetingAssistant.Summary;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -634,6 +635,41 @@ public sealed class RecordingCoordinatorTests
Assert.Equal(["Chris <chris@example.com>"], attendees); Assert.Equal(["Chris <chris@example.com>"], attendees);
} }
[Fact]
public async Task StopRemovesIdentifiedSpeakerAliasDuplicatesWhenCanonicalAttendeeExists()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk(new byte[] { 1, 0, 2, 0 }, 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var finalizer = new CapturingTranscriptFinalizer(
[
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")
]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider(), finalizer.FinalizeAsync),
new InMemoryTranscriptStore(),
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService(
"Guest03",
"Christopher",
["Christopher", "Chris"]));
await coordinator.StartAsync(CancellationToken.None);
noteStore.UpdateAttendees(["Ada", "Christopher", "Chris <chris@example.com>", "Chris"]);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
var attendees = Assert.IsType<List<string>>(noteStore.SavedNote?.Frontmatter.Attendees);
Assert.Equal(["Ada", "Christopher"], attendees);
}
[Fact] [Fact]
public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments() public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments()
{ {
@@ -975,6 +1011,43 @@ public sealed class RecordingCoordinatorTests
artifactStore.States); artifactStore.States);
} }
[Fact]
public async Task RecordingLifecycleRunsMeetingWorkflowEvents()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var workflowEngine = new CapturingMeetingWorkflowEngine();
var finalizer = new CapturingTranscriptFinalizer(
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")]);
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new FinalSegmentOnAudioCompletionProvider(),
finalizer.FinalizeAsync),
new InMemoryTranscriptStore(),
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(CreateOptionsWithoutFinalizer()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: new FixedSpeakerIdentificationService("Guest03", "Chris"),
meetingWorkflowEngine: workflowEngine);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Contains(workflowEngine.Events, e => e.Type == MeetingWorkflowEventType.Created);
Assert.Contains(workflowEngine.Events, e =>
e.Type == MeetingWorkflowEventType.StateTransition &&
e.FromState == AssistantContextState.CollectingMetadata &&
e.ToState == AssistantContextState.Transcribing);
Assert.Contains(workflowEngine.Events, e =>
e.Type == MeetingWorkflowEventType.SpeakerIdentified &&
e.SpeakerName == "Chris");
}
[Fact] [Fact]
public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments() public async Task VaultTranscriptStoreCreatesConfiguredFolderAndAppendsSegments()
{ {
@@ -1309,6 +1382,20 @@ public sealed class RecordingCoordinatorTests
} }
} }
private sealed class CapturingMeetingWorkflowEngine : IMeetingWorkflowEngine
{
public List<MeetingWorkflowEvent> Events { get; } = [];
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Events.Add(workflowEvent);
return Task.CompletedTask;
}
}
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{ {
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; } public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
+2
View File
@@ -16,6 +16,8 @@
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" /> <PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="NAudio" Version="2.3.0" /> <PackageReference Include="NAudio" Version="2.3.0" />
<PackageReference Include="NCalcSync" Version="5.12.0" />
<PackageReference Include="RazorLight" Version="2.3.1" />
<PackageReference Include="Whisper.net" Version="1.9.0" /> <PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" /> <PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
<PackageReference Include="YamlDotNet" Version="17.1.0" /> <PackageReference Include="YamlDotNet" Version="17.1.0" />
@@ -16,11 +16,18 @@ public sealed class MeetingAssistantOptions
public SpeakerIdentificationOptions SpeakerIdentification { get; set; } = new(); public SpeakerIdentificationOptions SpeakerIdentification { get; set; } = new();
public AutomationOptions Automation { get; set; } = new();
public AgentOptions Agent { get; set; } = new(); public AgentOptions Agent { get; set; } = new();
public ApiOptions Api { get; set; } = new(); public ApiOptions Api { get; set; } = new();
} }
public sealed class AutomationOptions
{
public string? RulesPath { get; set; } = "meeting-rules.local.yaml";
}
public sealed class HotkeyOptions public sealed class HotkeyOptions
{ {
public string Toggle { get; set; } = "Ctrl+Alt+M"; public string Toggle { get; set; } = "Ctrl+Alt+M";
@@ -25,6 +25,14 @@ public interface IMeetingArtifactStore
MeetingSessionArtifacts artifacts, MeetingSessionArtifacts artifacts,
AssistantContextState state, AssistantContextState state,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task AppendAssistantContextAsync(
MeetingSessionArtifacts artifacts,
string content,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
} }
public enum AssistantContextState public enum AssistantContextState
@@ -107,6 +107,30 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
artifacts.AssistantContextPath); artifacts.AssistantContextPath);
} }
public async Task AppendAssistantContextAsync(
MeetingSessionArtifacts artifacts,
string content,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(content))
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var prefix = File.Exists(artifacts.AssistantContextPath) &&
new FileInfo(artifacts.AssistantContextPath).Length > 0
? Environment.NewLine
: "";
await File.AppendAllTextAsync(
artifacts.AssistantContextPath,
prefix + content.TrimEnd() + Environment.NewLine,
cancellationToken);
logger.LogInformation(
"Appended automation context to assistant context note {AssistantContextPath}",
artifacts.AssistantContextPath);
}
private static string Render( private static string Render(
MeetingSessionArtifacts artifacts, MeetingSessionArtifacts artifacts,
MeetingNote meetingNote, MeetingNote meetingNote,
@@ -10,6 +10,8 @@ public sealed class MeetingArtifactFrontmatter
public DateTimeOffset? EndTime { get; set; } public DateTimeOffset? EndTime { get; set; }
public List<string>? Attendees { get; set; }
public string Meeting { get; set; } = ""; public string Meeting { get; set; } = "";
public string Transcript { get; set; } = ""; public string Transcript { get; set; } = "";
@@ -36,6 +38,7 @@ public static class MeetingArtifactFrontmatterRenderer
AppendScalar(builder, "title", frontmatter.Title); AppendScalar(builder, "title", frontmatter.Title);
AppendDateTime(builder, "start_time", frontmatter.StartTime); AppendDateTime(builder, "start_time", frontmatter.StartTime);
AppendDateTime(builder, "end_time", frontmatter.EndTime); AppendDateTime(builder, "end_time", frontmatter.EndTime);
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting); AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting);
AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript); AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript);
AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext); AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext);
@@ -142,6 +145,22 @@ public static class MeetingArtifactFrontmatterRenderer
builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"))); builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")));
} }
private static void AppendListIfNotNull(StringBuilder builder, string key, IReadOnlyList<string>? values)
{
if (values is null)
{
return;
}
builder.Append(key);
builder.AppendLine(":");
foreach (var value in values)
{
builder.Append("- ");
builder.AppendLine(EscapeListItem(value));
}
}
private static void AppendBlockScalar(StringBuilder builder, string key, string value) private static void AppendBlockScalar(StringBuilder builder, string key, string value)
{ {
if (string.IsNullOrEmpty(value)) if (string.IsNullOrEmpty(value))
+3
View File
@@ -6,6 +6,7 @@ using MeetingAssistant.Recording;
using MeetingAssistant.Speakers; using MeetingAssistant.Speakers;
using MeetingAssistant.Summary; using MeetingAssistant.Summary;
using MeetingAssistant.Transcription; using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -47,6 +48,8 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>(); builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>(); builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>(); builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
builder.Services.AddSingleton<AsrDiagnosticService>(); builder.Services.AddSingleton<AsrDiagnosticService>();
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>(); builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>(); builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
@@ -5,6 +5,7 @@ using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers; using MeetingAssistant.Speakers;
using MeetingAssistant.Summary; using MeetingAssistant.Summary;
using MeetingAssistant.Transcription; using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace MeetingAssistant.Recording; namespace MeetingAssistant.Recording;
@@ -24,6 +25,7 @@ public sealed class MeetingRecordingCoordinator
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer; private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
private readonly IDictationWordStore dictationWordStore; private readonly IDictationWordStore dictationWordStore;
private readonly ILaunchProfileOptionsProvider? launchProfiles; private readonly ILaunchProfileOptionsProvider? launchProfiles;
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
private readonly MeetingAssistantOptions options; private readonly MeetingAssistantOptions options;
private readonly ILogger<MeetingRecordingCoordinator> logger; private readonly ILogger<MeetingRecordingCoordinator> logger;
private readonly SemaphoreSlim gate = new(1, 1); private readonly SemaphoreSlim gate = new(1, 1);
@@ -47,7 +49,8 @@ public sealed class MeetingRecordingCoordinator
ISpeakerIdentificationService? speakerIdentificationService = null, ISpeakerIdentificationService? speakerIdentificationService = null,
IDictationWordStore? dictationWordStore = null, IDictationWordStore? dictationWordStore = null,
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null, ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
ILaunchProfileOptionsProvider? launchProfiles = null) ILaunchProfileOptionsProvider? launchProfiles = null,
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
{ {
this.audioSource = audioSource; this.audioSource = audioSource;
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory; this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
@@ -62,6 +65,7 @@ public sealed class MeetingRecordingCoordinator
this.speakerIdentificationService = speakerIdentificationService; this.speakerIdentificationService = speakerIdentificationService;
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance; this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
this.launchProfiles = launchProfiles; this.launchProfiles = launchProfiles;
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
this.options = options.Value; this.options = options.Value;
this.logger = logger; this.logger = logger;
} }
@@ -130,6 +134,15 @@ public sealed class MeetingRecordingCoordinator
assistantContextPath, assistantContextPath,
summaryPath); summaryPath);
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken); await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.Created(currentArtifacts),
runOptions,
cancellationToken);
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
currentArtifacts,
currentMeetingNote,
cancellationToken);
await transcriptStore.UpdateMetadataAsync( await transcriptStore.UpdateMetadataAsync(
currentSession, currentSession,
currentArtifacts, currentArtifacts,
@@ -407,6 +420,7 @@ public sealed class MeetingRecordingCoordinator
run.MarkLiveIdentificationAttempted(checkpoint); run.MarkLiveIdentificationAttempted(checkpoint);
await AddIdentifiedSpeakersToMeetingAttendeesAsync( await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches, result.AttendeeMatches,
run.Artifacts,
run.MeetingNotePath, run.MeetingNotePath,
run.Options, run.Options,
cancellationToken); cancellationToken);
@@ -607,6 +621,7 @@ public sealed class MeetingRecordingCoordinator
finishedSegments, finishedSegments,
run.GetSpeakerSamplesSnapshot(), run.GetSpeakerSamplesSnapshot(),
run.GetSpeakerMappingsSnapshot(), run.GetSpeakerMappingsSnapshot(),
run.Artifacts,
run.MeetingNotePath, run.MeetingNotePath,
run.Options, run.Options,
cancellationToken); cancellationToken);
@@ -618,6 +633,7 @@ public sealed class MeetingRecordingCoordinator
IReadOnlyList<TranscriptionSegment> finishedSegments, IReadOnlyList<TranscriptionSegment> finishedSegments,
IReadOnlyList<SpeakerAudioSample> samples, IReadOnlyList<SpeakerAudioSample> samples,
IReadOnlyDictionary<string, string> knownSpeakerMappings, IReadOnlyDictionary<string, string> knownSpeakerMappings,
MeetingSessionArtifacts artifacts,
string meetingNotePath, string meetingNotePath,
MeetingAssistantOptions runOptions, MeetingAssistantOptions runOptions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -640,6 +656,7 @@ public sealed class MeetingRecordingCoordinator
cancellationToken); cancellationToken);
await AddIdentifiedSpeakersToMeetingAttendeesAsync( await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches, result.AttendeeMatches,
artifacts,
meetingNotePath, meetingNotePath,
runOptions, runOptions,
cancellationToken); cancellationToken);
@@ -658,6 +675,7 @@ public sealed class MeetingRecordingCoordinator
private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync( private async Task AddIdentifiedSpeakersToMeetingAttendeesAsync(
IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches, IReadOnlyList<SpeakerIdentityAttendeeMatch>? matches,
MeetingSessionArtifacts artifacts,
string meetingNotePath, string meetingNotePath,
MeetingAssistantOptions runOptions, MeetingAssistantOptions runOptions,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -685,7 +703,17 @@ public sealed class MeetingRecordingCoordinator
.Append(match.DisplayName) .Append(match.DisplayName)
.Select(NormalizeAttendeeName) .Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name)) .Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .ToList();
if (RemoveDuplicateAcceptedAliases(meetingNote.Frontmatter.Attendees, match.DisplayName, acceptedNames))
{
existingNames = meetingNote.Frontmatter.Attendees
.Select(NormalizeAttendeeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
changed = true;
}
if (acceptedNames.Any(existingNames.Contains)) if (acceptedNames.Any(existingNames.Contains))
{ {
continue; continue;
@@ -697,20 +725,31 @@ public sealed class MeetingRecordingCoordinator
changed = true; changed = true;
} }
if (!changed) if (changed)
{ {
return; var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
{
currentMeetingNote = savedMeetingNote;
}
logger.LogInformation(
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}",
savedMeetingNote.Path);
} }
var savedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken); foreach (var match in matches)
if (string.Equals(currentMeetingNote?.Path, savedMeetingNote.Path, StringComparison.OrdinalIgnoreCase))
{ {
currentMeetingNote = savedMeetingNote; if (string.IsNullOrWhiteSpace(match.DisplayName))
} {
continue;
}
logger.LogInformation( await meetingWorkflowEngine.RunAsync(
"Added identified speaker(s) to meeting note attendees for {MeetingNotePath}", MeetingWorkflowEvent.SpeakerIdentified(artifacts, match.DisplayName.Trim()),
savedMeetingNote.Path); runOptions,
cancellationToken);
}
} }
private async Task<MeetingNote?> CompleteMeetingNoteAsync( private async Task<MeetingNote?> CompleteMeetingNoteAsync(
@@ -739,6 +778,59 @@ public sealed class MeetingRecordingCoordinator
return MeetingAttendeeNames.NormalizeDisplayName(attendee); return MeetingAttendeeNames.NormalizeDisplayName(attendee);
} }
private static bool RemoveDuplicateAcceptedAliases(
List<string> attendees,
string displayName,
IReadOnlyCollection<string> acceptedNames)
{
var normalizedDisplayName = NormalizeAttendeeName(displayName);
if (string.IsNullOrWhiteSpace(normalizedDisplayName) ||
!attendees.Any(attendee => string.Equals(
NormalizeAttendeeName(attendee),
normalizedDisplayName,
StringComparison.OrdinalIgnoreCase)))
{
return false;
}
var changed = false;
var displayNameKept = false;
var cleaned = new List<string>();
foreach (var attendee in attendees)
{
var normalizedAttendee = NormalizeAttendeeName(attendee);
if (string.Equals(normalizedAttendee, normalizedDisplayName, StringComparison.OrdinalIgnoreCase))
{
if (displayNameKept)
{
changed = true;
continue;
}
displayNameKept = true;
cleaned.Add(attendee);
continue;
}
if (acceptedNames.Contains(normalizedAttendee, StringComparer.OrdinalIgnoreCase))
{
changed = true;
continue;
}
cleaned.Add(attendee);
}
if (!changed)
{
return false;
}
attendees.Clear();
attendees.AddRange(cleaned);
return true;
}
private async Task RunSummaryAsync( private async Task RunSummaryAsync(
RecordingRun run, RecordingRun run,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -774,7 +866,7 @@ public sealed class MeetingRecordingCoordinator
AssistantContextState state, AssistantContextState state,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!run.TryTransitionTo(state)) if (!run.TryTransitionTo(state, out var fromState))
{ {
return; return;
} }
@@ -783,6 +875,10 @@ public sealed class MeetingRecordingCoordinator
run.Artifacts, run.Artifacts,
state, state,
cancellationToken); cancellationToken);
await meetingWorkflowEngine.RunAsync(
MeetingWorkflowEvent.StateTransition(run.Artifacts, fromState, state),
run.Options,
cancellationToken);
} }
private bool HasConfiguredFinalizer(MeetingAssistantOptions runOptions) private bool HasConfiguredFinalizer(MeetingAssistantOptions runOptions)
@@ -959,15 +1055,19 @@ public sealed class MeetingRecordingCoordinator
LiveIdentificationCancellationSource.Cancel(); LiveIdentificationCancellationSource.Cancel();
} }
public bool TryTransitionTo(AssistantContextState state) public bool TryTransitionTo(
AssistantContextState state,
out AssistantContextState fromState)
{ {
lock (stateGate) lock (stateGate)
{ {
if (StateRank(state) <= StateRank(ContextState)) if (StateRank(state) <= StateRank(ContextState))
{ {
fromState = ContextState;
return false; return false;
} }
fromState = ContextState;
ContextState = state; ContextState = state;
return true; return true;
} }
@@ -106,6 +106,7 @@ public sealed class MeetingSummaryTools
meetingNote, meetingNote,
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle, string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
artifacts.SummaryPath); artifacts.SummaryPath);
frontmatter.Attendees = await ResolveSummaryAttendeesAsync(meetingNote);
await File.WriteAllTextAsync( await File.WriteAllTextAsync(
artifacts.SummaryPath, artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown)); MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
@@ -571,6 +572,37 @@ public sealed class MeetingSummaryTools
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
} }
private async Task<List<string>?> ResolveSummaryAttendeesAsync(MeetingNote meetingNote)
{
var existingAttendees = await ReadExistingSummaryAttendeesAsync();
if (existingAttendees is not null)
{
return existingAttendees;
}
return meetingNote.Frontmatter.Attendees.Count == 0
? null
: meetingNote.Frontmatter.Attendees.ToList();
}
private async Task<List<string>?> ReadExistingSummaryAttendeesAsync()
{
if (!File.Exists(artifacts.SummaryPath))
{
return null;
}
var content = await File.ReadAllTextAsync(artifacts.SummaryPath);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return null;
}
var yaml = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter);
return yaml?.Attendees;
}
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path); private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
private sealed record FileLineEditMode( private sealed record FileLineEditMode(
@@ -633,6 +665,12 @@ public sealed class MeetingSummaryTools
public string? Summary { get; set; } public string? Summary { get; set; }
} }
private sealed class SummaryFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
public List<string>? Attendees { get; set; }
}
private static DateTimeOffset? ParseDateTime(string? value) private static DateTimeOffset? ParseDateTime(string? value)
{ {
return DateTimeOffset.TryParse(value, out var parsed) return DateTimeOffset.TryParse(value, out var parsed)
@@ -0,0 +1,60 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace MeetingAssistant.Workflow;
public interface IMeetingWorkflowRulesProvider
{
Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProvider
{
private readonly ILogger<FileMeetingWorkflowRulesProvider> logger;
private readonly IDeserializer yamlDeserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
public FileMeetingWorkflowRulesProvider(ILogger<FileMeetingWorkflowRulesProvider> logger)
{
this.logger = logger;
}
public async Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Automation.RulesPath))
{
return [];
}
var path = ResolvePath(options.Automation.RulesPath);
if (!File.Exists(path))
{
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
return [];
}
var yaml = await File.ReadAllTextAsync(path, cancellationToken);
if (string.IsNullOrWhiteSpace(yaml))
{
return [];
}
var rulesFile = yamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml)
?? new MeetingWorkflowRulesFile();
return rulesFile.Rules;
}
private static string ResolvePath(string configuredPath)
{
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
return Path.IsPathRooted(expanded)
? Path.GetFullPath(expanded)
: Path.GetFullPath(expanded);
}
}
@@ -0,0 +1,380 @@
using System.Collections;
using System.Globalization;
using System.Text.RegularExpressions;
using MeetingAssistant.MeetingNotes;
using NCalc;
using RazorLight;
namespace MeetingAssistant.Workflow;
public interface IMeetingWorkflowEngine
{
Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
{
public static NoopMeetingWorkflowEngine Instance { get; } = new();
public Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
{
private static readonly string[] ParameterNames =
[
"meeting.attendees.count",
"meeting.attendees",
"meeting.projects",
"meeting.title",
"meeting.state",
"event.type",
"state.from",
"state.to",
"speaker.name"
];
private readonly IMeetingWorkflowRulesProvider rulesProvider;
private readonly IMeetingNoteStore meetingNoteStore;
private readonly IMeetingArtifactStore meetingArtifactStore;
private readonly ILogger<MeetingWorkflowEngine> logger;
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
.UseNoProject()
.Build();
public MeetingWorkflowEngine(
IMeetingWorkflowRulesProvider rulesProvider,
IMeetingNoteStore meetingNoteStore,
IMeetingArtifactStore meetingArtifactStore,
ILogger<MeetingWorkflowEngine> logger)
{
this.rulesProvider = rulesProvider;
this.meetingNoteStore = meetingNoteStore;
this.meetingArtifactStore = meetingArtifactStore;
this.logger = logger;
}
public async Task RunAsync(
MeetingWorkflowEvent workflowEvent,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var rules = await rulesProvider.GetRulesAsync(options, cancellationToken);
if (rules.Count == 0)
{
return;
}
var meeting = await meetingNoteStore.ReadAsync(
workflowEvent.Artifacts.MeetingNotePath,
cancellationToken);
var noteChanged = false;
foreach (var rule in rules)
{
if (!MatchesTrigger(rule, workflowEvent) ||
!EvaluateConditions(rule.If, meeting, workflowEvent))
{
continue;
}
logger.LogInformation(
"Applying meeting workflow rule {RuleName} for event {EventType}",
rule.Name,
workflowEvent.Type);
var model = CreateTemplateModel(meeting, workflowEvent);
foreach (var step in rule.Steps)
{
noteChanged |= await ApplyStepAsync(
step,
meeting,
workflowEvent,
model,
cancellationToken);
model = CreateTemplateModel(meeting, workflowEvent);
}
}
if (noteChanged)
{
var saved = await meetingNoteStore.SaveAsync(meeting, options, cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
workflowEvent.Artifacts,
saved,
cancellationToken);
}
}
private static bool MatchesTrigger(
MeetingWorkflowRule rule,
MeetingWorkflowEvent workflowEvent)
{
if (rule.On.Count == 0)
{
return false;
}
return rule.On.Any(trigger => MatchesTrigger(trigger, workflowEvent));
}
private static bool MatchesTrigger(
MeetingWorkflowTrigger trigger,
MeetingWorkflowEvent workflowEvent)
{
if (trigger.Created is not null)
{
return workflowEvent.Type == MeetingWorkflowEventType.Created;
}
if (trigger.StateTransition is not null)
{
return workflowEvent.Type == MeetingWorkflowEventType.StateTransition &&
workflowEvent.FromState is { } from &&
workflowEvent.ToState is { } to &&
MeetingWorkflowStateNames.EqualsRuleName(from, trigger.StateTransition.From) &&
MeetingWorkflowStateNames.EqualsRuleName(to, trigger.StateTransition.To);
}
if (trigger.SpeakerIdentified is not null)
{
return workflowEvent.Type == MeetingWorkflowEventType.SpeakerIdentified &&
(string.IsNullOrWhiteSpace(trigger.SpeakerIdentified.Name) ||
string.Equals(
trigger.SpeakerIdentified.Name.Trim(),
workflowEvent.SpeakerName,
StringComparison.OrdinalIgnoreCase));
}
return false;
}
private bool EvaluateConditions(
IReadOnlyList<MeetingWorkflowCondition> conditions,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
{
return conditions.Count == 0 ||
conditions.All(condition => EvaluateCondition(condition, meeting, workflowEvent));
}
private bool EvaluateCondition(
MeetingWorkflowCondition condition,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
{
if (!string.IsNullOrWhiteSpace(condition.Condition))
{
return EvaluateExpression(condition.Condition, meeting, workflowEvent);
}
if (condition.And is { Count: > 0 })
{
return condition.And.All(child => EvaluateCondition(child, meeting, workflowEvent));
}
if (condition.Or is { Count: > 0 })
{
return condition.Or.Any(child => EvaluateCondition(child, meeting, workflowEvent));
}
if (condition.Not is not null)
{
return !EvaluateCondition(condition.Not, meeting, workflowEvent);
}
return true;
}
private bool EvaluateExpression(
string expressionText,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
{
var expression = new Expression(PrepareExpression(expressionText));
foreach (var (name, value) in BuildParameters(meeting, workflowEvent))
{
expression.Parameters[name] = value;
}
expression.Functions["contains"] = args =>
Contains(args[0].Evaluate(), Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture));
expression.Functions["starts_with"] = args =>
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.StartsWith(
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
StringComparison.OrdinalIgnoreCase) == true;
expression.Functions["ends_with"] = args =>
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.EndsWith(
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
StringComparison.OrdinalIgnoreCase) == true;
return Convert.ToBoolean(expression.Evaluate(), CultureInfo.InvariantCulture);
}
private async Task<bool> ApplyStepAsync(
MeetingWorkflowStep step,
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent,
MeetingWorkflowTemplateModel model,
CancellationToken cancellationToken)
{
var value = await RenderValueAsync(step.Value ?? "", model);
switch (step.Uses.Trim().ToLowerInvariant())
{
case "add_attendee":
return AddUnique(meeting.Frontmatter.Attendees, value);
case "remove_attendee":
return RemoveValue(meeting.Frontmatter.Attendees, value);
case "add_project":
return AddUnique(meeting.Frontmatter.Projects, value);
case "set_property":
return SetProperty(meeting, step.Property ?? step.Name, value);
case "add_context":
await meetingArtifactStore.AppendAssistantContextAsync(
workflowEvent.Artifacts,
value,
cancellationToken);
return false;
default:
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
}
}
private async Task<string> RenderValueAsync(
string template,
MeetingWorkflowTemplateModel model)
{
if (!template.Contains('@', StringComparison.Ordinal))
{
return template;
}
return await razorEngine.CompileRenderStringAsync(
Guid.NewGuid().ToString("N"),
template,
model);
}
private static bool SetProperty(
MeetingNote meeting,
string? property,
string value)
{
var normalized = property?.Trim().ToLowerInvariant();
if (normalized is "title" or "meeting.title")
{
if (string.Equals(meeting.Frontmatter.Title, value, StringComparison.Ordinal))
{
return false;
}
meeting.Frontmatter.Title = value;
return true;
}
throw new InvalidOperationException($"Unsupported meeting workflow property '{property}'.");
}
private static bool AddUnique(List<string> values, string value)
{
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
if (string.IsNullOrWhiteSpace(normalized) ||
values.Any(existing => string.Equals(existing, normalized, StringComparison.OrdinalIgnoreCase)))
{
return false;
}
values.Add(normalized);
return true;
}
private static bool RemoveValue(List<string> values, string value)
{
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
return values.RemoveAll(existing =>
string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase)) > 0;
}
private static bool Contains(object? haystack, string? needle)
{
if (string.IsNullOrEmpty(needle))
{
return true;
}
if (haystack is IEnumerable enumerable and not string)
{
return enumerable
.Cast<object?>()
.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture))
.Any(item => string.Equals(item, needle, StringComparison.OrdinalIgnoreCase));
}
return Convert.ToString(haystack, CultureInfo.InvariantCulture)?.Contains(
needle,
StringComparison.OrdinalIgnoreCase) == true;
}
private static string PrepareExpression(string expression)
{
var prepared = expression;
foreach (var parameter in ParameterNames.OrderByDescending(name => name.Length))
{
prepared = Regex.Replace(
prepared,
$@"(?<![\[\w.]){Regex.Escape(parameter)}(?![\]\w.])",
$"[{parameter}]",
RegexOptions.IgnoreCase);
}
return prepared;
}
private static IReadOnlyDictionary<string, object?> BuildParameters(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
{
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["meeting.attendees.count"] = meeting.Frontmatter.Attendees.Count,
["meeting.attendees"] = meeting.Frontmatter.Attendees,
["meeting.projects"] = meeting.Frontmatter.Projects,
["meeting.title"] = meeting.Frontmatter.Title,
["meeting.state"] = workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : "",
["event.type"] = workflowEvent.Type.ToString(),
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
["speaker.name"] = workflowEvent.SpeakerName
};
}
private static MeetingWorkflowTemplateModel CreateTemplateModel(
MeetingNote meeting,
MeetingWorkflowEvent workflowEvent)
{
return new MeetingWorkflowTemplateModel(
new MeetingWorkflowMeetingModel(
meeting.Frontmatter.Title,
meeting.Frontmatter.Attendees,
meeting.Frontmatter.Projects,
workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : ""),
new MeetingWorkflowEventModel(
workflowEvent.Type.ToString(),
workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : null,
workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : null),
string.IsNullOrWhiteSpace(workflowEvent.SpeakerName)
? null
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName));
}
}
@@ -0,0 +1,38 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Workflow;
public enum MeetingWorkflowEventType
{
Created,
StateTransition,
SpeakerIdentified
}
public sealed record MeetingWorkflowEvent(
MeetingWorkflowEventType Type,
MeetingSessionArtifacts Artifacts,
AssistantContextState? FromState = null,
AssistantContextState? ToState = null,
string? SpeakerName = null)
{
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
{
return new MeetingWorkflowEvent(MeetingWorkflowEventType.Created, artifacts);
}
public static MeetingWorkflowEvent StateTransition(
MeetingSessionArtifacts artifacts,
AssistantContextState from,
AssistantContextState to)
{
return new MeetingWorkflowEvent(MeetingWorkflowEventType.StateTransition, artifacts, from, to);
}
public static MeetingWorkflowEvent SpeakerIdentified(
MeetingSessionArtifacts artifacts,
string speakerName)
{
return new MeetingWorkflowEvent(MeetingWorkflowEventType.SpeakerIdentified, artifacts, SpeakerName: speakerName);
}
}
@@ -0,0 +1,123 @@
using MeetingAssistant.MeetingNotes;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Workflow;
public sealed class MeetingWorkflowRulesFile
{
[YamlMember(Alias = "rules")]
public List<MeetingWorkflowRule> Rules { get; set; } = [];
}
public sealed class MeetingWorkflowRule
{
[YamlMember(Alias = "name")]
public string Name { get; set; } = "";
[YamlMember(Alias = "on")]
public List<MeetingWorkflowTrigger> On { get; set; } = [];
[YamlMember(Alias = "if")]
public List<MeetingWorkflowCondition> If { get; set; } = [];
[YamlMember(Alias = "steps")]
public List<MeetingWorkflowStep> Steps { get; set; } = [];
}
public sealed class MeetingWorkflowTrigger
{
[YamlMember(Alias = "created")]
public object? Created { get; set; }
[YamlMember(Alias = "state_transition")]
public MeetingWorkflowStateTransitionTrigger? StateTransition { get; set; }
[YamlMember(Alias = "speaker_identified")]
public MeetingWorkflowSpeakerIdentifiedTrigger? SpeakerIdentified { get; set; }
}
public sealed class MeetingWorkflowStateTransitionTrigger
{
[YamlMember(Alias = "from")]
public string? From { get; set; }
[YamlMember(Alias = "to")]
public string? To { get; set; }
}
public sealed class MeetingWorkflowSpeakerIdentifiedTrigger
{
[YamlMember(Alias = "name")]
public string? Name { get; set; }
}
public sealed class MeetingWorkflowCondition
{
[YamlMember(Alias = "condition")]
public string? Condition { get; set; }
[YamlMember(Alias = "and")]
public List<MeetingWorkflowCondition>? And { get; set; }
[YamlMember(Alias = "or")]
public List<MeetingWorkflowCondition>? Or { get; set; }
[YamlMember(Alias = "not")]
public MeetingWorkflowCondition? Not { get; set; }
}
public sealed class MeetingWorkflowStep
{
[YamlMember(Alias = "uses")]
public string Uses { get; set; } = "";
[YamlMember(Alias = "property")]
public string? Property { get; set; }
[YamlMember(Alias = "name")]
public string? Name { get; set; }
[YamlMember(Alias = "value")]
public string? Value { get; set; }
}
public sealed record MeetingWorkflowTemplateModel(
MeetingWorkflowMeetingModel Meeting,
MeetingWorkflowEventModel Event,
MeetingWorkflowSpeakerModel? Speaker);
public sealed record MeetingWorkflowMeetingModel(
string Title,
IReadOnlyList<string> Attendees,
IReadOnlyList<string> Projects,
string State);
public sealed record MeetingWorkflowEventModel(
string Type,
string? From,
string? To);
public sealed record MeetingWorkflowSpeakerModel(string Name);
internal static class MeetingWorkflowStateNames
{
public static string ToRuleName(AssistantContextState state)
{
return state switch
{
AssistantContextState.CollectingMetadata => "collecting metadata",
AssistantContextState.Transcribing => "transcribing",
AssistantContextState.SpeakerRecognition => "speaker recognition",
AssistantContextState.Summarizing => "summarizing",
AssistantContextState.Finished => "finished",
AssistantContextState.Error => "error",
_ => "error"
};
}
public static bool EqualsRuleName(AssistantContextState state, string? value)
{
return string.IsNullOrWhiteSpace(value) ||
string.Equals(ToRuleName(state), value.Trim(), StringComparison.OrdinalIgnoreCase);
}
}
+3
View File
@@ -106,6 +106,9 @@
"MergeRecentIdentityAge": "14.00:00:00", "MergeRecentIdentityAge": "14.00:00:00",
"MatchTimeout": "00:03:00" "MatchTimeout": "00:03:00"
}, },
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
"Agent": { "Agent": {
"Endpoint": "https://litellm.schweigert.cloud", "Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY", "KeyEnv": "LITELLM_API_KEY",
+7
View File
@@ -160,6 +160,9 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"RecognitionStopTimeout": "00:00:10", "RecognitionStopTimeout": "00:00:10",
"DiarizeIntermediateResults": true "DiarizeIntermediateResults": true
}, },
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
},
"Agent": { "Agent": {
"Endpoint": "https://litellm.schweigert.cloud", "Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY", "KeyEnv": "LITELLM_API_KEY",
@@ -207,6 +210,10 @@ Assistant context notes keep their artifact links in frontmatter and expose a li
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter. `ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`. The initial supported steps are `add_attendee`, `remove_attendee`, `set_property`, `add_context`, and `add_project`.
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts. `Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget. `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget.
+302
View File
@@ -0,0 +1,302 @@
# Meeting Workflow Engine
Meeting Assistant has a small local workflow engine for meeting-specific automation. It is intended for rules that are too personal or environment-specific to hard-code, such as adding default attendees, cleaning meeting titles, binding projects, or adding context notes when known speakers are detected.
The workflow engine is deliberately narrow. It runs from a local YAML file, evaluates rules against the current meeting note read from disk, and applies a small set of meeting-safe mutations.
## Configuration
The rules file path is configured through `MeetingAssistant:Automation:RulesPath`.
```json
{
"MeetingAssistant": {
"Automation": {
"RulesPath": "meeting-rules.local.yaml"
}
}
}
```
`meeting-rules.local.yaml` is the default local rules file and is ignored by git. The path may be absolute, relative to the process working directory, or use environment variables.
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
## Execution Model
The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow event:
- `created`: after the meeting note and assistant context artifact are created.
- `state_transition`: after the assistant context state moves forward.
- `speaker_identified`: when live or final speaker identification reports a display name.
For every event, the engine:
1. Loads the current rules file.
2. Reads the latest meeting note from disk.
3. Evaluates every matching rule in file order.
4. Applies each matching rule's steps in order.
5. Rebuilds the template model after every step, so later steps can see earlier mutations.
6. Saves the meeting note once if any meeting-note step changed it.
7. Updates assistant-context frontmatter links/title from the saved meeting note after note changes.
Rules are best-effort automation. Invalid expressions, unknown steps, or unsupported properties currently fail the workflow event and should be covered by tests before the engine is broadened.
## YAML Shape
The top-level YAML document contains a `rules` array.
```yaml
rules:
- name: add-default-attendee
on:
- created: {}
if:
- condition: meeting.attendees.count = 0
steps:
- uses: add_attendee
value: Manuel
```
Each rule has:
- `name`: log-friendly rule name.
- `on`: one or more triggers. At least one must match.
- `if`: optional list of conditions. All top-level conditions must pass.
- `steps`: ordered actions to run when the rule matches.
## Triggers
### `created`
Runs after a meeting note is created.
```yaml
on:
- created: {}
```
### `state_transition`
Runs when the assistant context state transitions. `from` and `to` are optional filters; omitted filters match any value.
```yaml
on:
- state_transition:
from: collecting metadata
to: transcribing
```
Supported state names are:
- `collecting metadata`
- `transcribing`
- `speaker recognition`
- `summarizing`
- `finished`
- `error`
### `speaker_identified`
Runs when speaker identification reports a display name. `name` is optional; when supplied, it matches case-insensitively.
```yaml
on:
- speaker_identified:
name: Ada
```
## Conditions
Conditions use NCalc expressions. Dotted workflow variables may be written directly; the engine rewrites them into NCalc parameters internally.
```yaml
if:
- condition: meeting.attendees.count = 0
```
Available condition variables:
- `meeting.attendees.count`
- `meeting.attendees`
- `meeting.projects`
- `meeting.title`
- `meeting.state`
- `event.type`
- `state.from`
- `state.to`
- `speaker.name`
Available helper functions:
- `contains(haystack, needle)`: case-insensitive string or collection contains.
- `starts_with(value, prefix)`: case-insensitive string prefix check.
- `ends_with(value, suffix)`: case-insensitive string suffix check.
Nested boolean groups are supported:
```yaml
if:
- and:
- condition: contains(meeting.title, '[External]')
- not:
condition: contains(meeting.projects, 'Internal')
```
Top-level `if` entries are combined with `and`.
## Step Templating
Step `value` fields can be plain strings or Razor templates. Razor templates receive this model:
- `Model.Meeting.Title`
- `Model.Meeting.Attendees`
- `Model.Meeting.Projects`
- `Model.Meeting.State`
- `Model.Event.Type`
- `Model.Event.From`
- `Model.Event.To`
- `Model.Speaker.Name`
Example:
```yaml
steps:
- uses: add_context
value: "Known speaker identified: @Model.Speaker.Name"
```
The engine only invokes Razor when the value contains `@`.
## Steps
### `add_attendee`
Adds a normalized attendee display name to meeting note frontmatter if it is not already present case-insensitively.
```yaml
steps:
- uses: add_attendee
value: Manuel
```
### `remove_attendee`
Removes attendees whose normalized display name matches the rendered value.
```yaml
steps:
- uses: remove_attendee
value: "Guest"
```
### `set_property`
Sets a supported meeting property. The initial supported property is `title` or `meeting.title`.
```yaml
steps:
- uses: set_property
property: title
value: "@Model.Meeting.Title.Replace('[External] ', '')"
```
### `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.
```yaml
steps:
- uses: add_context
value: "Ada was identified; check project ownership notes."
```
### `add_project`
Adds a project name to meeting note frontmatter if it is not already present case-insensitively.
```yaml
steps:
- uses: add_project
value: Meeting Assistant
```
## Examples
Add a default attendee only for empty meetings:
```yaml
rules:
- name: add-me
on:
- created: {}
if:
- condition: meeting.attendees.count = 0
steps:
- uses: add_attendee
value: Manuel
```
Remove a title marker after metadata collection:
```yaml
rules:
- name: clean-external-marker
on:
- state_transition:
from: collecting metadata
if:
- condition: starts_with(meeting.title, '[External] ')
steps:
- uses: set_property
property: title
value: "@Model.Meeting.Title.Replace(\"[External] \", \"\")"
```
Add context when a speaker is identified:
```yaml
rules:
- name: note-ada
on:
- speaker_identified:
name: Ada
steps:
- uses: add_context
value: "Ada was identified. Check whether the architecture decision log needs an update."
```
Add context when metadata gathering ends with a specific attendee:
```yaml
rules:
- name: note-review-owner
on:
- state_transition:
from: collecting metadata
if:
- condition: contains(meeting.attendees, 'Manuel')
steps:
- uses: add_context
value: "Manuel is attending; include implementation follow-ups in the summary."
```
## Implementation Notes
Core files:
- `MeetingAssistant/Workflow/MeetingWorkflowEngine.cs`
- `MeetingAssistant/Workflow/MeetingWorkflowModels.cs`
- `MeetingAssistant/Workflow/MeetingWorkflowEvent.cs`
- `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs`
- `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs`
- `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs`
When extending the workflow engine:
1. Update OpenSpec requirements if behavior changes.
2. Add behavior tests through `MeetingWorkflowEngine` or the recording coordinator event surface.
3. Keep the supported trigger, condition, template model, and step lists in this document current.
4. Keep `README.md` as the short operational overview and link back here for details.
5. Keep the local rules file ignored by git; do not commit personal automation rules.
@@ -0,0 +1,15 @@
# Change: Add meeting automation rules
## Why
Manual meeting-specific adjustments such as adding default attendees, cleaning titles, binding projects, or adding context notes should be configurable without recompiling Meeting Assistant.
## What Changes
- Add a configurable local YAML rules file.
- Evaluate rules on meeting lifecycle events.
- Support a small workflow surface: triggers, conditions, and meeting/context mutation steps.
- Use a real expression engine for conditions and Razor syntax for step values.
## Impact
- Adds a local-only rules file path setting.
- Adds meeting automation runtime code and tests.
- Keeps the initial step surface intentionally small.
@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: Meeting automation rules are configurable
Meeting Assistant SHALL allow an optional local YAML rules file path to be configured.
When the configured rules file is missing or blank, Meeting Assistant SHALL continue without applying automation rules.
The local rules file SHALL be ignored by source control.
Rules SHALL be evaluated against the latest meeting note data read from disk for each event.
#### Scenario: Missing rules file is ignored
- **WHEN** Meeting Assistant handles a meeting event and no configured rules file exists
- **THEN** it leaves the meeting note and assistant context unchanged
#### Scenario: Created rule adds default attendee
- **GIVEN** a configured rule that triggers on meeting creation when `meeting.attendees.count = 0`
- **WHEN** Meeting Assistant creates a meeting note without attendees
- **THEN** it adds the configured attendee to the meeting note
### Requirement: Meeting automation rules support lifecycle triggers
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, and `speaker_identified`.
A `state_transition` trigger MAY filter by `from`, `to`, or both state values.
A `speaker_identified` trigger MAY filter by speaker name.
#### Scenario: State transition rule matches from and to
- **GIVEN** a configured rule that triggers on a state transition from `collecting metadata` to `transcribing`
- **WHEN** Meeting Assistant transitions that meeting from `collecting metadata` to `transcribing`
- **THEN** it applies the rule steps
#### Scenario: Speaker identified rule filters by name
- **GIVEN** a configured rule that triggers when speaker `Ada` is identified
- **WHEN** Meeting Assistant identifies speaker `Grace`
- **THEN** it does not apply the rule
- **WHEN** Meeting Assistant identifies speaker `Ada`
- **THEN** it applies the rule steps
### Requirement: Meeting automation rules support conditions and steps
Meeting Assistant SHALL support rule conditions using an expression engine.
Rules SHALL support nested `and`, `or`, and `not` condition groups.
Step values SHALL support Razor syntax against the current meeting event model.
Meeting Assistant SHALL support these initial rule steps:
- `add_attendee`
- `remove_attendee`
- `set_property`
- `add_context`
- `add_project`
#### 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: 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`
@@ -0,0 +1,12 @@
## 1. Implementation
- [x] 1.1 Add automation options and ignore the local rules YAML file.
- [x] 1.2 Load YAML rules from the configured local file.
- [x] 1.3 Evaluate triggers and nested conditions.
- [x] 1.4 Render step values with Razor syntax.
- [x] 1.5 Apply supported steps to meeting notes and assistant context.
- [x] 1.6 Fire rules from meeting creation, state transitions, and speaker identification.
## 2. Verification
- [x] 2.1 Cover created, state-transition, and speaker-identified paths.
- [x] 2.2 Cover nested condition logic and templated values.
- [x] 2.3 Run relevant tests and strict OpenSpec validation.
@@ -0,0 +1,12 @@
# Change: Copy attendees to summary frontmatter
## Why
The summary note should retain the final attendee list from the meeting note so the generated summary artifact can be filtered and reviewed without opening the meeting note.
## What Changes
- When the summary agent writes a summary note, copy attendees from the current meeting note into summary frontmatter.
- If the summary note already has an `attendees` frontmatter property, preserve that existing value.
## Impact
- Updates summary artifact frontmatter rendering.
- Adds focused summary tool tests.
@@ -0,0 +1,14 @@
## MODIFIED Requirements
### Requirement: Meeting Assistant generates meeting outputs
The summary note SHALL keep frontmatter links to meeting artifacts and SHALL copy `title`, `start_time`, `end_time`, and `attendees` from the meeting note when available. If the meeting note has no title, the summary agent SHALL be able to provide a title when writing the summary.
When the summary note already has an `attendees` frontmatter property, Meeting Assistant SHALL preserve the existing summary attendees instead of overwriting them from the meeting note.
#### Scenario: Summary copies final meeting attendees
- **WHEN** the summary agent writes a summary note after meeting attendees have changed
- **THEN** the summary note frontmatter includes the final meeting note attendees
#### Scenario: Existing summary attendees are preserved
- **GIVEN** an existing summary note has `attendees` in frontmatter
- **WHEN** the summary agent rewrites the summary
- **THEN** Meeting Assistant preserves the existing summary attendees
@@ -0,0 +1,8 @@
## 1. Implementation
- [x] 1.1 Render optional attendees in artifact frontmatter.
- [x] 1.2 Copy meeting-note attendees when writing a new summary.
- [x] 1.3 Preserve existing summary attendees when rewriting a summary.
## 2. Verification
- [x] 2.1 Add behavior tests for copied and preserved attendees.
- [x] 2.2 Run focused tests and strict OpenSpec validation.
@@ -0,0 +1,9 @@
# Change: Deduplicate attendee aliases after speaker identification
## Why
Speaker identification can confirm that multiple meeting-note attendee entries refer to the same person. When the attendee list contains both the identified speaker display name and one or more accepted aliases, later processing sees an inflated attendee count and noisier metadata.
## What Changes
- When a speaker is identified, remove attendee entries that match the identified speaker's aliases if the identified speaker display name is also present.
- Preserve the existing alias-only behavior: an attendee alias by itself still prevents adding another canonical attendee entry.
@@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: Speaker identity matches relabel transcripts
Meeting Assistant SHALL attempt to match unknown diarized speaker snippets against known speaker identities ordered by calculated meeting reference count.
Speaker identity matching SHALL use a dedicated Azure Speech diarization verifier component rather than the configured speech recognition pipeline.
The matcher SHALL test at most the configured batch size of known people per diarization session and continue with later batches until a match is found or no candidates remain.
The matcher SHALL prioritize identities whose canonical name or aliases match current meeting attendees.
After attendee-matched identities, the matcher SHALL order identities by calculated meeting reference count, filter out non-attendee identities whose last update is older than the configured active age, and cap the candidate set at the configured maximum match candidate count.
When a match is confirmed in final identity processing, Meeting Assistant SHALL store a meeting file reference for that identity.
When a match is confirmed and the identity has a canonical name, Meeting Assistant SHALL rewrite finished transcript segments for that diarized speaker with the canonical name.
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
When a match is confirmed and the meeting note attendees contain both the speaker display name and one or more accepted aliases for that same speaker, Meeting Assistant SHALL remove the alias attendee entries and keep the display name entry.
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
#### Scenario: Finished transcript is relabeled after a confirmed match
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
- **THEN** Meeting Assistant rewrites `Guest03` segments in the transcript as `Chris`
#### Scenario: Confirmed match stores meeting reference
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
- **THEN** Meeting Assistant stores the meeting note and transcript file addresses as a reference for `Chris`
#### Scenario: Confirmed match removes duplicate aliases
- **GIVEN** the speaker identity database contains canonical speaker `Christopher` with alias `Chris`
- **AND** the meeting note attendees contain both `Christopher` and `Chris <chris@example.com>`
- **WHEN** live or final speaker matching confirms a diarized speaker is `Christopher`
- **THEN** Meeting Assistant keeps `Christopher` in the meeting note attendees
- **AND** removes `Chris <chris@example.com>` from the meeting note attendees
@@ -0,0 +1,7 @@
## 1. Implementation
- [x] 1.1 Add a behavior test for removing alias duplicates when canonical attendee is present.
- [x] 1.2 Remove duplicate alias attendee entries during speaker identification.
## 2. Verification
- [x] 2.1 Run focused recording coordinator tests.
- [x] 2.2 Run strict OpenSpec validation.