Public Access
Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user