Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s

This commit is contained in:
2026-05-27 23:11:21 +02:00
parent c12febe029
commit 7777b349a5
37 changed files with 3190 additions and 121 deletions
@@ -67,7 +67,7 @@ public sealed class LaunchProfileOptionsProviderTests
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
@@ -86,7 +86,7 @@ public sealed class LaunchProfileOptionsProviderTests
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "default" &&
hotkey.Hotkey == "Ctrl+Alt+D" &&
hotkey.Hotkey == "Ctrl+Alt+Z" &&
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "english" &&
@@ -100,7 +100,7 @@ public sealed class LaunchProfileOptionsProviderTests
var provider = CreateProvider(new Dictionary<string, string?>
{
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
});
@@ -151,6 +151,36 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal(1, handler.RequestCount);
}
[Fact]
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
{
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""
{
"output": [
{
"type": "message",
"content": [
{
"type": "output_text",
"text": "Done."
}
]
}
]
}
""")
});
using var client = CreateClient(handler, reconnectionAttempts: 0);
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Tool, "tool result")]);
await client.GetResponseAsync([new ChatMessage(ChatRole.Assistant, "continue")]);
Assert.Equal(["user", "agent", "agent"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
{
@@ -209,6 +239,7 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]);
}
[Fact]
@@ -321,6 +352,8 @@ public sealed class LiteLlmResponsesChatClientTests
public List<string> RequestBodies { get; } = [];
public Dictionary<string, List<string>> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
@@ -329,6 +362,17 @@ public sealed class LiteLlmResponsesChatClientTests
RequestBodies.Add(request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken));
foreach (var header in request.Headers)
{
if (!RequestHeaders.TryGetValue(header.Key, out var values))
{
values = [];
RequestHeaders[header.Key] = values;
}
values.AddRange(header.Value);
}
return handler(request);
}
}
@@ -16,6 +16,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md");
var assistantContextPath = Path.Combine(root, "Meetings", "Assistant Context", "context.md");
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
@@ -47,10 +50,98 @@ public sealed class MeetingSummaryArtifactResolverTests
Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath);
Assert.Equal(Path.Combine(root, "Meetings", "Assistant Context", "context.md"), artifacts.AssistantContextPath);
Assert.Equal(assistantContextPath, artifacts.AssistantContextPath);
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
}
[Fact]
public async Task ResolverPrefersArtifactLinksFromRequestedSummaryFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var notes = Path.Combine(root, "Meetings", "Notes");
var summaries = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md");
var transcriptPath = Path.Combine(root, "Meetings", "Transcripts", "transcript.md");
var summaryContextPath = Path.Combine(root, "Meetings", "Assistant Context", "summary-context.md");
Directory.CreateDirectory(Path.GetDirectoryName(summaryContextPath)!);
await File.WriteAllTextAsync(summaryContextPath, "# Summary-linked Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
---
title: Meeting
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/stale-context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
""");
await File.WriteAllTextAsync(
Path.Combine(summaries, "summary.md"),
"""
---
meeting: "[[../Notes/meeting|Meeting Note]]"
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/summary-context|Assistant Context]]"
---
""");
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = notes,
SummariesFolder = summaries
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(transcriptPath, artifacts.TranscriptPath);
Assert.Equal(summaryContextPath, artifacts.AssistantContextPath);
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
}
[Fact]
public async Task ResolverRejectsSummaryWhenLinkedAssistantContextIsMissing()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var notes = Path.Combine(root, "Meetings", "Notes");
var summaries = Path.Combine(root, "Meetings", "Summaries");
Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries);
await File.WriteAllTextAsync(
Path.Combine(notes, "meeting.md"),
"""
---
title: Meeting
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/missing-context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
""");
var options = Options.Create(new MeetingAssistantOptions
{
Vault = new VaultOptions
{
MeetingNotesFolder = notes,
SummariesFolder = summaries
}
});
var resolver = new MeetingSummaryArtifactResolver(
options,
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
Assert.Null(artifacts);
}
[Fact]
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
{
@@ -85,6 +176,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(englishNotes);
Directory.CreateDirectory(englishSummaries);
var notePath = Path.Combine(englishNotes, "meeting.md");
var assistantContextPath = Path.Combine(englishRoot, "Assistant Context", "context.md");
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
await File.WriteAllTextAsync(
notePath,
"""
@@ -24,6 +24,13 @@ public sealed class MeetingSummaryInstructionBuilderTests
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
Assert.Contains("include only the most relevant cropped screenshots", instructions);
Assert.Contains("Do not include every cropped screenshot", instructions);
Assert.Contains("Use add_attendee and remove_attendee", instructions);
Assert.Contains("partial screenshot", instructions);
Assert.Contains("override_speaker", instructions);
Assert.Contains("very certain", instructions);
Assert.Contains("merge=true", instructions);
Assert.Contains("delete_identity", instructions);
Assert.Contains("wrongfully matched", instructions);
}
[Fact]
@@ -0,0 +1,198 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using Microsoft.Extensions.Logging.Abstractions;
namespace MeetingAssistant.Tests;
public sealed class MeetingSummaryRetryRunnerTests
{
[Fact]
public async Task TriggerStartsRetryInBackgroundAndTransitionsAssistantContextState()
{
var artifacts = new MeetingSessionArtifacts(
"meeting.md",
"transcript.md",
"context.md",
"summary.md");
var resolver = new FixedSummaryArtifactResolver(artifacts);
var pipeline = new BlockingSummaryPipeline();
var artifactStore = new CapturingArtifactStore();
var runner = new MeetingSummaryRetryRunner(
resolver,
pipeline,
artifactStore,
NullLogger<MeetingSummaryRetryRunner>.Instance);
var result = await runner.TriggerAsync(
"summary.md",
new MeetingAssistantOptions(),
CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("summary.md", result.SummaryPath);
Assert.False(pipeline.Completed);
await pipeline.WaitUntilStartedAsync();
Assert.Equal([AssistantContextState.Summarizing], artifactStore.States);
pipeline.CompleteSuccessfully();
await artifactStore.WaitForStateCountAsync(2);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Finished],
artifactStore.States);
}
[Fact]
public async Task TriggerMarksAssistantContextErrorWhenRetryFails()
{
var artifacts = new MeetingSessionArtifacts(
"meeting.md",
"transcript.md",
"context.md",
"summary.md");
var resolver = new FixedSummaryArtifactResolver(artifacts);
var pipeline = new BlockingSummaryPipeline();
var artifactStore = new CapturingArtifactStore();
var runner = new MeetingSummaryRetryRunner(
resolver,
pipeline,
artifactStore,
NullLogger<MeetingSummaryRetryRunner>.Instance);
await runner.TriggerAsync(
"summary.md",
new MeetingAssistantOptions(),
CancellationToken.None);
await pipeline.WaitUntilStartedAsync();
pipeline.CompleteWithError();
await artifactStore.WaitForStateCountAsync(2);
Assert.Equal(
[AssistantContextState.Summarizing, AssistantContextState.Error],
artifactStore.States);
}
private sealed class FixedSummaryArtifactResolver : IMeetingSummaryArtifactResolver
{
private readonly MeetingSessionArtifacts? artifacts;
public FixedSummaryArtifactResolver(MeetingSessionArtifacts? artifacts)
{
this.artifacts = artifacts;
}
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
CancellationToken cancellationToken)
{
return Task.FromResult(artifacts);
}
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return Task.FromResult(artifacts);
}
}
private sealed class BlockingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly TaskCompletionSource started =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<MeetingSummaryRunResult> completion =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public bool Completed { get; private set; }
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
started.TrySetResult();
var result = await completion.Task.WaitAsync(cancellationToken);
Completed = true;
return result;
}
public Task WaitUntilStartedAsync()
{
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void CompleteSuccessfully()
{
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "ok"));
}
public void CompleteWithError()
{
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "failed", false, "failed"));
}
}
private sealed class CapturingArtifactStore : IMeetingArtifactStore
{
private TaskCompletionSource stateChanged =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<AssistantContextState> States { get; } = [];
public Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
CancellationToken cancellationToken)
{
States.Add(state);
var observed = stateChanged;
observed.TrySetResult();
stateChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return Task.CompletedTask;
}
public async Task WaitForStateCountAsync(int count)
{
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
while (DateTimeOffset.UtcNow < deadline)
{
if (States.Count >= count)
{
return;
}
await stateChanged.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
}
throw new TimeoutException($"Expected {count} state changes.");
}
}
}
@@ -47,9 +47,11 @@ public sealed class MeetingSummaryToolTests
Assert.Equal("User note line.", await tools.ReadUserNotes());
Assert.Equal("", await tools.ReadGlossary());
Assert.False(tools.SummaryWasWritten);
var result = await tools.WriteSummary("# Summary\n\n- Done.");
Assert.Equal(artifacts.SummaryPath, result);
Assert.True(tools.SummaryWasWritten);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary);
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
@@ -151,6 +153,278 @@ public sealed class MeetingSummaryToolTests
Assert.DoesNotContain("- Stale Project", summary);
}
[Fact]
public async Task ToolsCanAddAndRemoveMeetingAttendees()
{
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:
- Ada
- Grace Hopper <grace@example.com>
projects:
- Meeting Assistant
transcript: "[[../Transcripts/transcript|Transcript]]"
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
summary: "[[../Summaries/summary|Summary]]"
---
User note line.
""");
var tools = new MeetingSummaryTools(artifacts);
Assert.Equal("Added attendee Manuel.", await tools.AddAttendee("Manuel"));
Assert.Equal("Attendee Ada already exists.", await tools.AddAttendee("Ada"));
Assert.Equal("Removed attendee Grace Hopper.", await tools.RemoveAttendee("Grace Hopper"));
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
Assert.Contains("- Ada", meetingNote);
Assert.Contains("- Manuel", meetingNote);
Assert.DoesNotContain("Grace Hopper", meetingNote);
Assert.Contains("User note line.", meetingNote);
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", meetingNote);
}
[Fact]
public async Task ToolsOverrideSpeakerInTranscriptAndRecordOverride()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: Hello from Sabrina.
[00:00:02] Guest-02: Hello from someone else.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.Equal("Overrode speaker Guest-01 with Sabrina.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: Hello from Sabrina.", transcript);
Assert.Contains("[00:00:02] Guest-02: Hello from someone else.", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("meeting-assistant:speaker-override", context);
Assert.Contains("\"from\":\"Guest-01\"", context);
Assert.Contains("\"to\":\"Sabrina\"", context);
}
[Fact]
public async Task ToolsRefuseOverrideWhenAssistantContextIsMissing()
{
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.TranscriptPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: Hello from Sabrina.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Guest-01: Hello from Sabrina.", transcript);
}
[Fact]
public async Task ToolsRefuseOverrideWhenReplacementSpeakerAlreadyExistsWithoutMerge()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: I am probably Sabrina.
[00:00:02] Sabrina: I am already in the transcript.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
Assert.Equal(
"Refused: transcript already contains speaker Sabrina. Call override_speaker with merge=true only when you are certain both speakers are the same identity.",
result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Guest-01: I am probably Sabrina.", transcript);
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.DoesNotContain("meeting-assistant:speaker-override", context);
}
[Fact]
public async Task ToolsMergeOverrideWhenReplacementSpeakerAlreadyExistsWithMerge()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Guest-01: I am Sabrina.
[00:00:02] Sabrina: I am already in the transcript.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina", merge: true);
Assert.Equal("Merged speaker Guest-01 into Sabrina.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: I am Sabrina.", transcript);
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
Assert.DoesNotContain("Guest-01:", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("meeting-assistant:speaker-override", context);
Assert.Contains("\"from\":\"Guest-01\"", context);
Assert.Contains("\"to\":\"Sabrina\"", context);
Assert.Contains("\"merge\":true", context);
}
[Fact]
public async Task ToolsDeleteIdentityInTranscriptAndRecordDeletion()
{
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.TranscriptPath)!);
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Sabrina: This was incorrectly matched.
[00:00:02] Removed-1: An earlier removed speaker.
[00:00:03] Guest-02: Hello from someone else.
[00:00:04] Sabrina: Another incorrect line.
""");
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
Existing context.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.DeleteIdentity("Sabrina");
Assert.Equal("Deleted identity Sabrina and relabeled transcript speaker mentions to Removed-2.", result);
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Removed-2: This was incorrectly matched.", transcript);
Assert.Contains("[00:00:02] Removed-1: An earlier removed speaker.", transcript);
Assert.Contains("[00:00:03] Guest-02: Hello from someone else.", transcript);
Assert.Contains("[00:00:04] Removed-2: Another incorrect line.", transcript);
Assert.DoesNotContain("Sabrina:", transcript);
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
Assert.Contains("state: summarizing", context);
Assert.Contains("meeting-assistant:speaker-identity-deletion", context);
Assert.Contains("\"identity\":\"Sabrina\"", context);
Assert.Contains("\"replacement\":\"Removed-2\"", context);
}
[Fact]
public async Task ToolsRefuseDeleteIdentityWhenAssistantContextIsMissing()
{
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.TranscriptPath)!);
await File.WriteAllTextAsync(
artifacts.TranscriptPath,
"""
# Meeting Transcript
[00:00:01] Sabrina: This was incorrectly matched.
""");
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.DeleteIdentity("Sabrina");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
Assert.Contains("[00:00:01] Sabrina: This was incorrectly matched.", transcript);
}
[Fact]
public async Task ReadToolsSupportClampedLineRanges()
{
@@ -236,6 +510,23 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("replacement\ninserted\nline two", context);
}
[Fact]
public async Task ToolsRefuseWriteContextWhenAssistantContextIsMissing()
{
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"));
var tools = new MeetingSummaryTools(artifacts);
var result = await tools.WriteContext("new context");
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
Assert.False(File.Exists(artifacts.AssistantContextPath));
}
[Fact]
public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody()
{
@@ -582,6 +582,139 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task ToggleToDifferentLaunchProfileWhileRecordingSwitchesPipelineWithoutNewArtifactsOrSummary()
{
var audioSource = new ControlledAudioSource();
var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory();
var transcriptStore = new InMemoryTranscriptStore();
var metadataProvider = new CountingMeetingMetadataProvider();
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var launchProfiles = CreateLaunchProfiles(
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
var coordinator = new MeetingRecordingCoordinator(
audioSource,
pipelineFactory,
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
summaryPipeline,
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
meetingMetadataProvider: metadataProvider,
launchProfiles: launchProfiles);
var started = await coordinator.ToggleAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("default chunk:2");
var switched = await coordinator.ToggleAsync("english", CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("english chunk:4");
Assert.True(switched.IsRecording);
Assert.Equal(started.TranscriptPath, switched.TranscriptPath);
Assert.Equal(started.MeetingNotePath, switched.MeetingNotePath);
Assert.Equal(started.AssistantContextPath, switched.AssistantContextPath);
Assert.Equal(started.SummaryPath, switched.SummaryPath);
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
Assert.Contains(transcriptStore.Segments, segment =>
segment.Speaker == "System" &&
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
Assert.Null(summaryPipeline.Artifacts);
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task ToggleToDifferentLaunchProfileBuffersAudioCapturedWhilePreviousPipelineDrains()
{
var audioSource = new ControlledAudioSource();
var pipelineFactory = new BlockingProfileSwitchSpeechRecognitionPipelineFactory();
var transcriptStore = new InMemoryTranscriptStore();
var launchProfiles = CreateLaunchProfiles(
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
var coordinator = new MeetingRecordingCoordinator(
audioSource,
pipelineFactory,
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
await transcriptStore.WaitForTextAsync("default chunk:2");
var switchTask = coordinator.ToggleAsync("english", CancellationToken.None);
await pipelineFactory.WaitUntilFirstPipelineDrainIsBlockedAsync();
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0, 3, 0], 16000, 1), CancellationToken.None);
Assert.False(switchTask.IsCompleted);
pipelineFactory.ReleaseFirstPipelineDrain();
await switchTask.WaitAsync(TimeSpan.FromSeconds(5));
await transcriptStore.WaitForTextAsync("english chunk:6");
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task ToggleToDifferentLaunchProfileClearsFutureSpeakerMappings()
{
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore();
var speakerIdentification = new SingleMappingSpeakerIdentificationService("Guest03", "Chris");
var launchProfiles = CreateLaunchProfiles(
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"),
enableSpeakerIdentification: true);
var options = launchProfiles.GetRequiredProfile(null).Options;
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new ProfileSwitchSpeechRecognitionPipelineFactory("Guest03"),
transcriptStore,
new InMemoryMeetingNoteStore(),
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(),
new InMemoryRecordedAudioStore(),
new CapturingMeetingSummaryPipeline(),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification,
launchProfiles: launchProfiles);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
await transcriptStore.WaitForTextAsync("default chunk:");
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Speaker == "Chris"));
await coordinator.ToggleAsync("english", CancellationToken.None);
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
await transcriptStore.WaitForTextAsync("english chunk:");
var afterMarker = transcriptStore.Segments
.SkipWhile(segment => !segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal))
.Skip(1)
.ToList();
Assert.Contains(afterMarker, segment => segment.Speaker == "Guest03");
Assert.DoesNotContain(afterMarker, segment => segment.Speaker == "Chris");
await coordinator.StopAsync(CancellationToken.None);
}
[Fact]
public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
{
@@ -1263,6 +1396,118 @@ public sealed class RecordingCoordinatorTests
artifactStore.States);
}
[Fact]
public async Task FinalSpeakerIdentityProcessingUsesAttendeesUpdatedBySummary()
{
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var noteStore = new InMemoryMeetingNoteStore();
var speakerIdentification = new CapturingFinalSpeakerIdentificationService();
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 AttendeeUpdatingSummaryPipeline(noteStore, ["Summary Attendee"]),
Options.Create(new MeetingAssistantOptions()),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(["Summary Attendee"], speakerIdentification.FinalAttendees);
}
[Fact]
public async Task FinalSpeakerIdentityProcessingAppliesSummarySpeakerOverrides()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService();
var finalizer = new CapturingTranscriptFinalizer(
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
var options = CreateOptionsWithoutFinalizer();
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new FinalSegmentOnAudioCompletionProvider(),
finalizer.FinalizeAsync),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
new SummarySpeakerOverridePipeline("Guest-01", "Sabrina"),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal(("Guest-01", "Sabrina"), speakerIdentification.Overrides.Single());
Assert.Equal("Sabrina", speakerIdentification.FinalSegments.Single().Speaker);
Assert.Equal("Sabrina", transcriptStore.ReplacedSegments.Single().Speaker);
Assert.Equal("Sabrina", speakerIdentification.FinalKnownSpeakerMappings["Guest-01"]);
}
[Fact]
public async Task FinalSpeakerIdentityProcessingAppliesSummaryIdentityDeletions()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService("Guest-01", "Sabrina");
var finalizer = new CapturingTranscriptFinalizer(
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
var options = CreateOptionsWithoutFinalizer();
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
var coordinator = new MeetingRecordingCoordinator(
audioSource,
new TestSpeechRecognitionPipelineFactory(
new FinalSegmentOnAudioCompletionProvider(),
finalizer.FinalizeAsync),
transcriptStore,
noteStore,
new CapturingMeetingNoteOpener(),
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
new SummaryIdentityDeletionPipeline("Sabrina"),
Options.Create(options),
NullLogger<MeetingRecordingCoordinator>.Instance,
speakerIdentificationService: speakerIdentification);
await coordinator.StartAsync(CancellationToken.None);
await audioSource.WaitUntilCapturedAsync();
await coordinator.StopAsync(CancellationToken.None);
Assert.Equal("Sabrina", speakerIdentification.DeletedIdentities.Single());
Assert.Equal("Removed-1", speakerIdentification.FinalSegments.Single().Speaker);
Assert.Equal("Removed-1", transcriptStore.ReplacedSegments.Single().Speaker);
}
[Fact]
public async Task RecordingLifecycleRunsMeetingWorkflowEvents()
{
@@ -1398,25 +1643,39 @@ public sealed class RecordingCoordinatorTests
};
}
private static AudioChunk CreateThreeSecondChunk()
{
return new AudioChunk(new byte[16000 * 2 * 3], 16000, 1);
}
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
string defaultRoot,
string englishRoot)
string englishRoot,
bool enableSpeakerIdentification = false)
{
var values = new Dictionary<string, string?>
{
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
["MeetingAssistant:Agent:Model"] = "default-summary-model",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
};
if (enableSpeakerIdentification)
{
values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true";
values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00";
values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050";
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
["MeetingAssistant:Agent:Model"] = "default-summary-model",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
})
.AddInMemoryCollection(values)
.Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration);
}
@@ -1737,6 +1996,13 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{
private readonly bool createAssistantContextFile;
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
{
this.createAssistantContextFile = createAssistantContextFile;
}
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
public List<AssistantContextState> States { get; } = [];
@@ -1758,6 +2024,21 @@ public sealed class RecordingCoordinatorTests
ContextMeetingNote = meetingNote;
Agenda = agenda;
ScheduledEnd = scheduledEnd;
if (createAssistantContextFile)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
return File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
# Assistant Context
""",
cancellationToken);
}
return Task.CompletedTask;
}
@@ -1855,6 +2136,19 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class CountingMeetingMetadataProvider : IMeetingMetadataProvider
{
public int CallCount { get; private set; }
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
CallCount++;
return Task.FromResult<MeetingMetadata?>(null);
}
}
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
{
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
@@ -1953,6 +2247,102 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class AttendeeUpdatingSummaryPipeline : IMeetingSummaryPipeline
{
private readonly InMemoryMeetingNoteStore noteStore;
private readonly IReadOnlyList<string> attendees;
public AttendeeUpdatingSummaryPipeline(
InMemoryMeetingNoteStore noteStore,
IReadOnlyList<string> attendees)
{
this.noteStore = noteStore;
this.attendees = attendees;
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
noteStore.UpdateAttendees(attendees);
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return RunAsync(artifacts, cancellationToken);
}
}
private sealed class SummarySpeakerOverridePipeline : IMeetingSummaryPipeline
{
private readonly string sourceSpeaker;
private readonly string targetSpeaker;
public SummarySpeakerOverridePipeline(
string sourceSpeaker,
string targetSpeaker)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
}
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(
artifacts.AssistantContextPath,
new SpeakerOverride(sourceSpeaker, targetSpeaker),
cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return RunAsync(artifacts, cancellationToken);
}
}
private sealed class SummaryIdentityDeletionPipeline : IMeetingSummaryPipeline
{
private readonly string identity;
public SummaryIdentityDeletionPipeline(string identity)
{
this.identity = identity;
}
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
artifacts.TranscriptPath,
identity,
cancellationToken);
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
artifacts.AssistantContextPath,
deletion,
cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
}
public Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return RunAsync(artifacts, cancellationToken);
}
}
private sealed class BlockingFirstFinalizer
{
private readonly TaskCompletionSource firstFinalizerBlocked =
@@ -2224,6 +2614,129 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class ProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly string speaker;
public ProfileSwitchSpeechRecognitionPipelineFactory(string speaker = "Unknown")
{
this.speaker = speaker;
}
public List<string?> ProfileNames { get; } = [];
public ISpeechRecognitionPipeline Create()
{
return Create(null);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
ProfileNames.Add(launchProfileName);
return new TestSpeechRecognitionPipeline(
new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", speaker),
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
}
private sealed class BlockingProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
{
private readonly TaskCompletionSource firstPipelineDrainBlocked =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource releaseFirstPipelineDrain =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private int created;
public List<string?> ProfileNames { get; } = [];
public ISpeechRecognitionPipeline Create()
{
return Create(null);
}
public ISpeechRecognitionPipeline Create(string? launchProfileName)
{
ProfileNames.Add(launchProfileName);
var createIndex = Interlocked.Increment(ref created);
var provider = createIndex == 1
? new BlockingOnCompletionProfileSwitchTranscriptionProvider(
launchProfileName ?? "default",
firstPipelineDrainBlocked,
releaseFirstPipelineDrain)
: new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", "Unknown");
return new TestSpeechRecognitionPipeline(
provider,
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
}
public Task WaitUntilFirstPipelineDrainIsBlockedAsync()
{
return firstPipelineDrainBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5));
}
public void ReleaseFirstPipelineDrain()
{
releaseFirstPipelineDrain.TrySetResult();
}
}
private class ProfileSwitchTranscriptionProvider : IStreamingTranscriptionProvider
{
private readonly string profileName;
private readonly string speaker;
public ProfileSwitchTranscriptionProvider(string profileName, string speaker)
{
this.profileName = profileName;
this.speaker = speaker;
}
public virtual async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var chunk in audio.WithCancellation(cancellationToken))
{
yield return new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.FromSeconds(3),
speaker,
$"{profileName} chunk:{chunk.Pcm.Length} has enough words for identification.");
}
}
}
private sealed class BlockingOnCompletionProfileSwitchTranscriptionProvider : ProfileSwitchTranscriptionProvider
{
private readonly TaskCompletionSource drainBlocked;
private readonly TaskCompletionSource releaseDrain;
public BlockingOnCompletionProfileSwitchTranscriptionProvider(
string profileName,
TaskCompletionSource drainBlocked,
TaskCompletionSource releaseDrain)
: base(profileName, "Unknown")
{
this.drainBlocked = drainBlocked;
this.releaseDrain = releaseDrain;
}
public override async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
IAsyncEnumerable<AudioChunk> audio,
SpeechRecognitionPipelineOptions options,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var segment in base.TranscribeAsync(audio, options, cancellationToken))
{
yield return segment;
}
drainBlocked.TrySetResult();
await releaseDrain.Task.WaitAsync(cancellationToken);
}
}
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
@@ -2348,6 +2861,143 @@ public sealed class RecordingCoordinatorTests
}
}
private sealed class SingleMappingSpeakerIdentificationService : ISpeakerIdentificationService
{
private readonly string sourceSpeaker;
private readonly string targetSpeaker;
private int mapped;
public SingleMappingSpeakerIdentificationService(string sourceSpeaker, string targetSpeaker)
{
this.sourceSpeaker = sourceSpeaker;
this.targetSpeaker = targetSpeaker;
}
public TaskCompletionSource IdentificationObserved { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
IdentificationObserved.TrySetResult();
if (Interlocked.Exchange(ref mapped, 1) == 0)
{
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments,
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
[new SpeakerIdentityAttendeeMatch(targetSpeaker, [targetSpeaker])]));
}
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CapturingFinalSpeakerIdentificationService : ISpeakerIdentificationService
{
public IReadOnlyList<string> FinalAttendees { get; private set; } = [];
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
FinalAttendees = request.MeetingNote.Frontmatter.Attendees.ToList();
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CapturingOverrideSpeakerIdentificationService : ISpeakerIdentificationService
{
private readonly string? readOnlySourceSpeaker;
private readonly string? readOnlyTargetSpeaker;
public CapturingOverrideSpeakerIdentificationService(
string? readOnlySourceSpeaker = null,
string? readOnlyTargetSpeaker = null)
{
this.readOnlySourceSpeaker = readOnlySourceSpeaker;
this.readOnlyTargetSpeaker = readOnlyTargetSpeaker;
}
public List<(string SourceSpeaker, string TargetSpeaker)> Overrides { get; } = [];
public List<string> DeletedIdentities { get; } = [];
public IReadOnlyList<TranscriptionSegment> FinalSegments { get; private set; } = [];
public IReadOnlyDictionary<string, string> FinalKnownSpeakerMappings { get; private set; } =
new Dictionary<string, string>();
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
public Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(readOnlySourceSpeaker) ||
string.IsNullOrWhiteSpace(readOnlyTargetSpeaker))
{
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
return Task.FromResult(new SpeakerIdentificationResult(
request.Segments.Select(segment => segment.Speaker == readOnlySourceSpeaker
? segment with { Speaker = readOnlyTargetSpeaker }
: segment).ToList(),
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[readOnlySourceSpeaker] = readOnlyTargetSpeaker
}));
}
public Task ApplySpeakerOverrideAsync(
SpeakerIdentificationRequest request,
string sourceSpeaker,
string targetSpeaker,
CancellationToken cancellationToken)
{
Overrides.Add((sourceSpeaker, targetSpeaker));
return Task.CompletedTask;
}
public Task DeleteSpeakerIdentityAsync(
string identity,
CancellationToken cancellationToken)
{
DeletedIdentities.Add(identity);
return Task.CompletedTask;
}
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
FinalSegments = request.Segments;
FinalKnownSpeakerMappings = request.KnownSpeakerMappings ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
}
}
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
{
public List<SpeakerIdentificationRequest> Requests { get; } = [];
@@ -0,0 +1,13 @@
using MeetingAssistant;
namespace MeetingAssistant.Tests;
public sealed class ScreenshotOcrOptionsTests
{
[Fact]
public void DefaultPromptAsksForCompleteOrPartialVisibleParticipantEvidence()
{
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
}
}
@@ -220,6 +220,74 @@ public sealed class SpeakerIdentityServiceTests
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
}
[Fact]
public async Task SpeakerOverrideAttachesCurrentSampleToExistingNamedIdentity()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1);
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [9, 9, 9], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
await service.ProcessFinishedTranscriptAsync(
fixture.CreateRequest(
["Sabrina"],
[segment with { Speaker = "Sabrina" }],
knownSpeakerMappings: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Guest-01"] = "Sabrina"
}),
CancellationToken.None);
var saved = await fixture.LoadIdentityAsync(sabrina.Id);
Assert.Equal("Sabrina", saved.CanonicalName);
Assert.Equal(2, saved.References.Count);
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 9, 9, 9 }));
var allIdentities = await fixture.Context.SpeakerIdentities.ToListAsync();
Assert.Single(allIdentities);
}
[Fact]
public async Task SpeakerOverrideCreatesNamedIdentityWhenNoAcceptedNameExists()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
var saved = await fixture.Context.SpeakerIdentities
.Include(identity => identity.References)
.Include(identity => identity.Snippets)
.SingleAsync();
Assert.Equal("Sabrina", saved.CanonicalName);
Assert.Single(saved.References);
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 }));
}
[Fact]
public async Task SpeakerIdentityDeletionRemovesAcceptedNameFromDatabase()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1, aliases: ["Sabi"]);
await fixture.AddIdentityAsync([], [4, 5, 6], "Manuel", referenceCount: 1);
var service = fixture.CreateService();
await service.DeleteSpeakerIdentityAsync("Sabi", CancellationToken.None);
var identities = await fixture.Context.SpeakerIdentities.ToListAsync();
Assert.DoesNotContain(identities, identity => identity.Id == sabrina.Id);
Assert.Contains(identities, identity => identity.CanonicalName == "Manuel");
}
[Fact]
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
{