Public Access
Add meeting workflow automation
This commit is contained in:
@@ -54,6 +54,8 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("title: Meeting", 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("attendees:", summary);
|
||||
Assert.Contains("- Ada", summary);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", 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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -634,6 +635,41 @@ public sealed class RecordingCoordinatorTests
|
||||
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]
|
||||
public async Task LiveSpeakerIdentityMatchRelabelsCurrentAndFutureTranscriptSegments()
|
||||
{
|
||||
@@ -975,6 +1011,43 @@ public sealed class RecordingCoordinatorTests
|
||||
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]
|
||||
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
|
||||
{
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
|
||||
Reference in New Issue
Block a user