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?> var provider = CreateProvider(new Dictionary<string, string?>
{ {
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M", ["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:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E", ["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D", ["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
@@ -86,7 +86,7 @@ public sealed class LaunchProfileOptionsProviderTests
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot); hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
Assert.Contains(hotkeys, hotkey => Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "default" && hotkey.ProfileName == "default" &&
hotkey.Hotkey == "Ctrl+Alt+D" && hotkey.Hotkey == "Ctrl+Alt+Z" &&
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording); hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
Assert.Contains(hotkeys, hotkey => Assert.Contains(hotkeys, hotkey =>
hotkey.ProfileName == "english" && hotkey.ProfileName == "english" &&
@@ -100,7 +100,7 @@ public sealed class LaunchProfileOptionsProviderTests
var provider = CreateProvider(new Dictionary<string, string?> var provider = CreateProvider(new Dictionary<string, string?>
{ {
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M", ["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:Screenshots:Hotkey"] = "Ctrl+Alt+S",
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US" ["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
}); });
@@ -151,6 +151,36 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal(1, handler.RequestCount); 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] [Fact]
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached() public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
{ {
@@ -209,6 +239,7 @@ public sealed class LiteLlmResponsesChatClientTests
Assert.Equal("Done.", response.Text); Assert.Equal("Done.", response.Text);
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths); Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]); Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]);
} }
[Fact] [Fact]
@@ -321,6 +352,8 @@ public sealed class LiteLlmResponsesChatClientTests
public List<string> RequestBodies { get; } = []; public List<string> RequestBodies { get; } = [];
public Dictionary<string, List<string>> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
protected override async Task<HttpResponseMessage> SendAsync( protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, HttpRequestMessage request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -329,6 +362,17 @@ public sealed class LiteLlmResponsesChatClientTests
RequestBodies.Add(request.Content is null RequestBodies.Add(request.Content is null
? string.Empty ? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken)); : 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); return handler(request);
} }
} }
@@ -16,6 +16,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(notes); Directory.CreateDirectory(notes);
Directory.CreateDirectory(summaries); Directory.CreateDirectory(summaries);
var notePath = Path.Combine(notes, "meeting.md"); 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( await File.WriteAllTextAsync(
notePath, notePath,
""" """
@@ -47,10 +50,98 @@ public sealed class MeetingSummaryArtifactResolverTests
Assert.NotNull(artifacts); Assert.NotNull(artifacts);
Assert.Equal(notePath, artifacts.MeetingNotePath); Assert.Equal(notePath, artifacts.MeetingNotePath);
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath); 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); 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] [Fact]
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder() public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
{ {
@@ -85,6 +176,9 @@ public sealed class MeetingSummaryArtifactResolverTests
Directory.CreateDirectory(englishNotes); Directory.CreateDirectory(englishNotes);
Directory.CreateDirectory(englishSummaries); Directory.CreateDirectory(englishSummaries);
var notePath = Path.Combine(englishNotes, "meeting.md"); 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( await File.WriteAllTextAsync(
notePath, notePath,
""" """
@@ -24,6 +24,13 @@ public sealed class MeetingSummaryInstructionBuilderTests
Assert.Contains("You are the Meeting Assistant summary agent.", instructions); Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
Assert.Contains("include only the most relevant cropped screenshots", instructions); Assert.Contains("include only the most relevant cropped screenshots", instructions);
Assert.Contains("Do not include every cropped screenshot", 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] [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("User note line.", await tools.ReadUserNotes());
Assert.Equal("", await tools.ReadGlossary()); Assert.Equal("", await tools.ReadGlossary());
Assert.False(tools.SummaryWasWritten);
var result = await tools.WriteSummary("# Summary\n\n- Done."); var result = await tools.WriteSummary("# Summary\n\n- Done.");
Assert.Equal(artifacts.SummaryPath, result); Assert.Equal(artifacts.SummaryPath, result);
Assert.True(tools.SummaryWasWritten);
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath); var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
Assert.Contains("title: Meeting", summary); Assert.Contains("title: Meeting", summary);
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary); Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
@@ -151,6 +153,278 @@ public sealed class MeetingSummaryToolTests
Assert.DoesNotContain("- Stale Project", summary); 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] [Fact]
public async Task ReadToolsSupportClampedLineRanges() public async Task ReadToolsSupportClampedLineRanges()
{ {
@@ -236,6 +510,23 @@ public sealed class MeetingSummaryToolTests
Assert.Contains("replacement\ninserted\nline two", context); 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] [Fact]
public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody() public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody()
{ {
@@ -582,6 +582,139 @@ public sealed class RecordingCoordinatorTests
await coordinator.StopAsync(CancellationToken.None); 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] [Fact]
public async Task StartUsesLaunchProfileStorageAndSummaryOptions() public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
{ {
@@ -1263,6 +1396,118 @@ public sealed class RecordingCoordinatorTests
artifactStore.States); 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] [Fact]
public async Task RecordingLifecycleRunsMeetingWorkflowEvents() 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( private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
string defaultRoot, 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() var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(values)
{
["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"
})
.Build(); .Build();
return new ConfigurationLaunchProfileOptionsProvider(configuration); return new ConfigurationLaunchProfileOptionsProvider(configuration);
} }
@@ -1737,6 +1996,13 @@ public sealed class RecordingCoordinatorTests
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
{ {
private readonly bool createAssistantContextFile;
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
{
this.createAssistantContextFile = createAssistantContextFile;
}
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; } public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
public List<AssistantContextState> States { get; } = []; public List<AssistantContextState> States { get; } = [];
@@ -1758,6 +2024,21 @@ public sealed class RecordingCoordinatorTests
ContextMeetingNote = meetingNote; ContextMeetingNote = meetingNote;
Agenda = agenda; Agenda = agenda;
ScheduledEnd = scheduledEnd; ScheduledEnd = scheduledEnd;
if (createAssistantContextFile)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
return File.WriteAllTextAsync(
artifacts.AssistantContextPath,
"""
---
state: summarizing
---
# Assistant Context
""",
cancellationToken);
}
return Task.CompletedTask; 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 private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
{ {
public Task<MeetingMetadata?> GetCurrentMeetingAsync( 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 sealed class BlockingFirstFinalizer
{ {
private readonly TaskCompletionSource firstFinalizerBlocked = 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 sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
{ {
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize; 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 private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
{ {
public List<SpeakerIdentificationRequest> Requests { get; } = []; 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); 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] [Fact]
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase() public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
{ {
+3 -2
View File
@@ -45,6 +45,7 @@ public sealed class ScreenshotOcrOptions
This screenshot was captured during a meeting. Extract information that is useful for meeting notes. This screenshot was captured during a meeting. Extract information that is useful for meeting notes.
Identify who appears to be talking, who appears to be presenting, and what is being presented when visible. Identify who appears to be talking, who appears to be presenting, and what is being presented when visible.
If people or participant tiles are visible, state whether it is clear from the screenshot exactly who is in the meeting or whether the visible people are only a partial participant result.
If the screenshot contains slides, capture the visible text in clean markdown. If the screenshot contains slides, capture the visible text in clean markdown.
If the screenshot contains a diagram, convert it to Mermaid when possible and preserve labels accurately. If the screenshot contains a diagram, convert it to Mermaid when possible and preserve labels accurately.
If it contains another kind of shared screen or scene, describe the scene and the relevant visible information. If it contains another kind of shared screen or scene, describe the scene and the relevant visible information.
@@ -83,7 +84,7 @@ public sealed class HotkeyOptions
{ {
public string Toggle { get; set; } = "Ctrl+Alt+M"; public string Toggle { get; set; } = "Ctrl+Alt+M";
public string Abort { get; set; } = "Ctrl+Alt+D"; public string Abort { get; set; } = "Ctrl+Alt+Z";
} }
public sealed class VaultOptions public sealed class VaultOptions
@@ -309,7 +310,7 @@ public sealed class AgentOptions
public string KeyEnv { get; set; } = "LITELLM_API_KEY"; public string KeyEnv { get; set; } = "LITELLM_API_KEY";
public string Model { get; set; } = "copilot-gpt-5.5"; public string Model { get; set; } = "chatgpt/gpt-5.5";
public bool EnableThinking { get; set; } = true; public bool EnableThinking { get; set; } = true;
+18 -21
View File
@@ -57,6 +57,7 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>(); builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>(); builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>(); builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>(); builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>(); builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
builder.Services.AddSingleton<AsrDiagnosticService>(); builder.Services.AddSingleton<AsrDiagnosticService>();
@@ -298,61 +299,53 @@ static async Task<IResult> RunCurrentSummaryAsync(
app.MapPost("/meetings/summary/retry", async ( app.MapPost("/meetings/summary/retry", async (
SummaryRetryRequest request, SummaryRetryRequest request,
IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryRetryRunner summaryRetryRunner,
IMeetingSummaryPipeline summaryPipeline,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await RetrySummaryAsync( await RetrySummaryAsync(
null, null,
request.SummaryPath, request.SummaryPath,
artifactResolver, summaryRetryRunner,
summaryPipeline,
options.Value, options.Value,
null, null,
cancellationToken)); cancellationToken));
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async ( app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
string launchProfile, string launchProfile,
SummaryRetryRequest request, SummaryRetryRequest request,
IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryRetryRunner summaryRetryRunner,
IMeetingSummaryPipeline summaryPipeline,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles, ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await RetrySummaryAsync( await RetrySummaryAsync(
launchProfile, launchProfile,
request.SummaryPath, request.SummaryPath,
artifactResolver, summaryRetryRunner,
summaryPipeline,
options.Value, options.Value,
launchProfiles, launchProfiles,
cancellationToken)); cancellationToken));
app.MapGet("/meetings/summary/retry", async ( app.MapGet("/meetings/summary/retry", async (
string summaryPath, string summaryPath,
IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryRetryRunner summaryRetryRunner,
IMeetingSummaryPipeline summaryPipeline,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await RetrySummaryAsync( await RetrySummaryAsync(
null, null,
summaryPath, summaryPath,
artifactResolver, summaryRetryRunner,
summaryPipeline,
options.Value, options.Value,
null, null,
cancellationToken)); cancellationToken));
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async ( app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
string launchProfile, string launchProfile,
string summaryPath, string summaryPath,
IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryRetryRunner summaryRetryRunner,
IMeetingSummaryPipeline summaryPipeline,
IOptions<MeetingAssistantOptions> options, IOptions<MeetingAssistantOptions> options,
ILaunchProfileOptionsProvider launchProfiles, ILaunchProfileOptionsProvider launchProfiles,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
await RetrySummaryAsync( await RetrySummaryAsync(
launchProfile, launchProfile,
summaryPath, summaryPath,
artifactResolver, summaryRetryRunner,
summaryPipeline,
options.Value, options.Value,
launchProfiles, launchProfiles,
cancellationToken)); cancellationToken));
@@ -360,8 +353,7 @@ app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
static async Task<IResult> RetrySummaryAsync( static async Task<IResult> RetrySummaryAsync(
string? launchProfile, string? launchProfile,
string? summaryPath, string? summaryPath,
IMeetingSummaryArtifactResolver artifactResolver, IMeetingSummaryRetryRunner summaryRetryRunner,
IMeetingSummaryPipeline summaryPipeline,
MeetingAssistantOptions defaultOptions, MeetingAssistantOptions defaultOptions,
ILaunchProfileOptionsProvider? launchProfiles, ILaunchProfileOptionsProvider? launchProfiles,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -372,13 +364,18 @@ static async Task<IResult> RetrySummaryAsync(
} }
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles); var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken); var trigger = await summaryRetryRunner.TriggerAsync(summaryPath, profileOptions, cancellationToken);
if (artifacts is null) if (trigger is null)
{ {
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." }); return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
} }
return Results.Ok(await summaryPipeline.RunAsync(artifacts, profileOptions, cancellationToken)); return Results.Accepted(value: new
{
summaryPath = trigger.SummaryPath,
assistantContextPath = trigger.AssistantContextPath,
state = "summarizing"
});
} }
app.MapPost("/asr/transcribe-file", async ( app.MapPost("/asr/transcribe-file", async (
@@ -97,9 +97,24 @@ public sealed class MeetingRecordingCoordinator
string? launchProfileName, string? launchProfileName,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
return IsRecording if (!IsRecording)
? await StopAsync(cancellationToken) {
: await StartAsync(launchProfileName, cancellationToken); return await StartAsync(launchProfileName, cancellationToken);
}
if (string.IsNullOrWhiteSpace(launchProfileName))
{
return await StopAsync(cancellationToken);
}
var targetProfile = ResolveProfile(launchProfileName);
var run = currentRun;
if (run is not null && !run.IsLaunchProfile(targetProfile.Name))
{
return await SwitchProfileAsync(run, targetProfile, cancellationToken);
}
return await StopAsync(cancellationToken);
} }
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken) public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
@@ -119,7 +134,8 @@ public sealed class MeetingRecordingCoordinator
return CurrentStatus; return CurrentStatus;
} }
var runOptions = ResolveOptions(launchProfileName); var launchProfile = ResolveProfile(launchProfileName);
var runOptions = launchProfile.Options;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken); currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken); var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var startedAt = DateTimeOffset.Now; var startedAt = DateTimeOffset.Now;
@@ -173,6 +189,7 @@ public sealed class MeetingRecordingCoordinator
pipeline, pipeline,
pipelineOptions, pipelineOptions,
runOptions, runOptions,
launchProfile.Name,
runOptions.SpeakerIdentification.LiveSampleBufferDuration, runOptions.SpeakerIdentification.LiveSampleBufferDuration,
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker); runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None); run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
@@ -318,12 +335,70 @@ public sealed class MeetingRecordingCoordinator
cancellationToken); cancellationToken);
} }
private async Task<RecordingStatus> SwitchProfileAsync(
RecordingRun run,
LaunchProfile targetProfile,
CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
if (!ReferenceEquals(currentRun, run) || run.IsCaptureStopping || run.IsLaunchProfile(targetProfile.Name))
{
return CurrentStatus;
}
await run.PipelineGate.WaitAsync(cancellationToken);
try
{
logger.LogInformation(
"Switching active meeting transcription profile from {FromProfile} to {ToProfile}",
run.LaunchProfileName,
targetProfile.Name);
var pipeline = speechRecognitionPipelineFactory.Create(targetProfile.Name);
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(
targetProfile.Options,
run.MeetingNotePath,
cancellationToken);
await pipeline.InitializeAsync(pipelineOptions, cancellationToken);
await run.Pipeline.CompleteAsync(cancellationToken);
await run.WaitForLiveTranscriptTasksAsync(cancellationToken);
run.ResetSpeakerIdentification();
run.MarkProfileSwitched();
var marker = new TranscriptionSegment(
TimeSpan.Zero,
TimeSpan.Zero,
"System",
$"Transcription profile changed to {targetProfile.Name}.");
run.AddLiveSegment(marker);
await transcriptStore.AppendAsync(run.Session, marker, cancellationToken);
run.ReplacePipeline(pipeline, pipelineOptions, targetProfile.Options, targetProfile.Name);
run.AddLiveTranscriptTask(Task.Run(
() => StoreLiveTranscriptAsync(run, pipeline, run.TranscriptionCancellation),
CancellationToken.None));
}
finally
{
run.PipelineGate.Release();
}
return CurrentStatus;
}
finally
{
gate.Release();
}
}
private async Task RecordAsync(RecordingRun run) private async Task RecordAsync(RecordingRun run)
{ {
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation); await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
var liveTranscriptTask = Task.Run( run.AddLiveTranscriptTask(Task.Run(
() => StoreLiveTranscriptAsync(run, run.TranscriptionCancellation), () => StoreLiveTranscriptAsync(run, run.Pipeline, run.TranscriptionCancellation),
CancellationToken.None); CancellationToken.None));
var captureTask = Task.Run( var captureTask = Task.Run(
() => CaptureAudioAsync(run), () => CaptureAudioAsync(run),
CancellationToken.None); CancellationToken.None);
@@ -335,7 +410,7 @@ public sealed class MeetingRecordingCoordinator
{ {
await captureTask; await captureTask;
await run.Pipeline.CompleteAsync(run.TranscriptionCancellation); await run.Pipeline.CompleteAsync(run.TranscriptionCancellation);
await liveTranscriptTask; await run.WaitForLiveTranscriptTasksAsync(run.TranscriptionCancellation);
run.CancelLiveIdentification(); run.CancelLiveIdentification();
await liveIdentificationTask; await liveIdentificationTask;
await run.RecordedAudio.DisposeAsync(); await run.RecordedAudio.DisposeAsync();
@@ -366,7 +441,9 @@ public sealed class MeetingRecordingCoordinator
run.TranscriptionCancellation); run.TranscriptionCancellation);
} }
await RunSummaryAsync(run, run.TranscriptionCancellation); var summaryState = await RunSummaryAsync(run, run.TranscriptionCancellation);
await CompletePendingFinalSpeakerIdentificationAsync(run, run.TranscriptionCancellation);
await TransitionMeetingAsync(run, summaryState, CancellationToken.None);
} }
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested) catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
{ {
@@ -383,7 +460,7 @@ public sealed class MeetingRecordingCoordinator
{ {
run.CancelLiveIdentification(); run.CancelLiveIdentification();
await run.RecordedAudio.DeleteAsync(CancellationToken.None); await run.RecordedAudio.DeleteAsync(CancellationToken.None);
await run.Pipeline.DisposeAsync(); await run.DisposePipelinesAsync();
await CompleteRunAsync(run); await CompleteRunAsync(run);
} }
} }
@@ -407,9 +484,8 @@ public sealed class MeetingRecordingCoordinator
chunk.Channels); chunk.Channels);
} }
run.AppendAudio(chunk);
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation); await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
await run.Pipeline.WriteAsync(chunk, run.CaptureCancellation); await run.WriteAudioAsync(chunk, run.CaptureCancellation);
} }
logger.LogInformation( logger.LogInformation(
@@ -432,9 +508,10 @@ public sealed class MeetingRecordingCoordinator
private async Task StoreLiveTranscriptAsync( private async Task StoreLiveTranscriptAsync(
RecordingRun run, RecordingRun run,
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await foreach (var segment in run.Pipeline.ReadLiveTranscriptAsync(cancellationToken)) await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
{ {
run.TryAddSpeakerSample(segment); run.TryAddSpeakerSample(segment);
var relabeledSegment = run.Relabel(segment); var relabeledSegment = run.Relabel(segment);
@@ -716,6 +793,18 @@ public sealed class MeetingRecordingCoordinator
RecordingRun run, RecordingRun run,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (run.HasSwitchedProfile)
{
var liveSegments = run.GetLiveSegmentsSnapshot();
run.SetPendingFinalSpeakerIdentification(
run.RecordedAudio.AudioPath,
liveSegments,
run.GetSpeakerSamplesSnapshot(),
run.GetSpeakerMappingsSnapshot());
await transcriptStore.ReplaceAsync(run.Session, liveSegments, cancellationToken);
return;
}
var finishedSegments = await run.Pipeline.ReadFinishedTranscriptAsync( var finishedSegments = await run.Pipeline.ReadFinishedTranscriptAsync(
run.RecordedAudio.AudioPath, run.RecordedAudio.AudioPath,
await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken), await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken),
@@ -728,19 +817,34 @@ public sealed class MeetingRecordingCoordinator
finishedSegments = finishedSegments finishedSegments = finishedSegments
.Select(run.Relabel) .Select(run.Relabel)
.ToList(); .ToList();
finishedSegments = await IdentifySpeakersAsync( var samples = run.GetSpeakerSamplesSnapshot();
var knownSpeakerMappings = run.GetSpeakerMappingsSnapshot().ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase);
var finishedSpeakerResult = await IdentifyFinishedSpeakersForTranscriptAsync(
run.RecordedAudio.AudioPath, run.RecordedAudio.AudioPath,
finishedSegments, finishedSegments,
run.GetSpeakerSamplesSnapshot(), samples,
run.GetSpeakerMappingsSnapshot(), knownSpeakerMappings,
run.Artifacts, run.Artifacts,
run.MeetingNotePath, run.MeetingNotePath,
run.Options, run.Options,
cancellationToken); cancellationToken);
await transcriptStore.ReplaceAsync(run.Session, finishedSegments, cancellationToken); foreach (var (sourceSpeaker, targetSpeaker) in finishedSpeakerResult.SpeakerMappings)
{
knownSpeakerMappings[sourceSpeaker] = targetSpeaker;
}
run.SetPendingFinalSpeakerIdentification(
run.RecordedAudio.AudioPath,
finishedSpeakerResult.Segments,
samples,
knownSpeakerMappings);
await transcriptStore.ReplaceAsync(run.Session, finishedSpeakerResult.Segments, cancellationToken);
} }
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync( private async Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersForTranscriptAsync(
string audioPath, string audioPath,
IReadOnlyList<TranscriptionSegment> finishedSegments, IReadOnlyList<TranscriptionSegment> finishedSegments,
IReadOnlyList<SpeakerAudioSample> samples, IReadOnlyList<SpeakerAudioSample> samples,
@@ -752,13 +856,13 @@ public sealed class MeetingRecordingCoordinator
{ {
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath)) if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath))
{ {
return finishedSegments; return new SpeakerIdentificationResult(finishedSegments, new Dictionary<string, string>());
} }
try try
{ {
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken); var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync( var result = await speakerIdentificationService.IdentifyFinishedSpeakersAsync(
new SpeakerIdentificationRequest( new SpeakerIdentificationRequest(
audioPath, audioPath,
meetingNote, meetingNote,
@@ -772,7 +876,7 @@ public sealed class MeetingRecordingCoordinator
meetingNotePath, meetingNotePath,
runOptions, runOptions,
cancellationToken); cancellationToken);
return result.Segments; return result;
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
@@ -780,8 +884,92 @@ public sealed class MeetingRecordingCoordinator
} }
catch (Exception exception) catch (Exception exception)
{ {
logger.LogWarning(exception, "Speaker identity learning failed; keeping ASR speaker labels"); logger.LogWarning(exception, "Finished speaker identity matching failed; keeping ASR speaker labels");
return finishedSegments; return new SpeakerIdentificationResult(finishedSegments, new Dictionary<string, string>());
}
}
private async Task CompletePendingFinalSpeakerIdentificationAsync(
RecordingRun run,
CancellationToken cancellationToken)
{
var pending = run.TakePendingFinalSpeakerIdentification();
if (pending is null ||
speakerIdentificationService is null ||
string.IsNullOrWhiteSpace(run.MeetingNotePath))
{
return;
}
try
{
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken);
var segments = pending.Segments.ToList();
var knownSpeakerMappings = pending.KnownSpeakerMappings.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase);
var speakerOverrides = await SpeakerOverrideArtifacts.ReadFromAssistantContextAsync(
run.Artifacts.AssistantContextPath,
cancellationToken);
foreach (var speakerOverride in speakerOverrides)
{
await speakerIdentificationService.ApplySpeakerOverrideAsync(
new SpeakerIdentificationRequest(
pending.AudioPath,
meetingNote,
segments,
pending.Samples,
knownSpeakerMappings),
speakerOverride.From,
speakerOverride.To,
cancellationToken);
segments = segments
.Select(segment => string.Equals(segment.Speaker, speakerOverride.From, StringComparison.OrdinalIgnoreCase)
? segment with { Speaker = speakerOverride.To }
: segment)
.ToList();
knownSpeakerMappings[speakerOverride.From] = speakerOverride.To;
}
var identityDeletions = await SpeakerOverrideArtifacts.ReadIdentityDeletionsFromAssistantContextAsync(
run.Artifacts.AssistantContextPath,
cancellationToken);
foreach (var deletion in identityDeletions)
{
await speakerIdentificationService.DeleteSpeakerIdentityAsync(
deletion.Identity,
cancellationToken);
segments = segments
.Select(segment => string.Equals(segment.Speaker, deletion.Identity, StringComparison.OrdinalIgnoreCase)
? segment with { Speaker = deletion.Replacement }
: segment)
.ToList();
}
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
new SpeakerIdentificationRequest(
pending.AudioPath,
meetingNote,
segments,
pending.Samples,
knownSpeakerMappings),
cancellationToken);
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
result.AttendeeMatches,
run.Artifacts,
run.MeetingNotePath,
run.Options,
cancellationToken);
await transcriptStore.ReplaceAsync(run.Session, result.Segments, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Final speaker identity learning failed after summary");
} }
} }
@@ -943,13 +1131,13 @@ public sealed class MeetingRecordingCoordinator
return true; return true;
} }
private async Task RunSummaryAsync( private async Task<AssistantContextState> RunSummaryAsync(
RecordingRun run, RecordingRun run,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (run.IsAborted) if (run.IsAborted)
{ {
return; return AssistantContextState.Finished;
} }
try try
@@ -963,10 +1151,7 @@ public sealed class MeetingRecordingCoordinator
AssistantContextState.Summarizing, AssistantContextState.Summarizing,
cancellationToken); cancellationToken);
var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken); var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken);
await TransitionMeetingAsync( return result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error;
run,
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
CancellationToken.None);
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
@@ -975,10 +1160,7 @@ public sealed class MeetingRecordingCoordinator
catch (Exception exception) catch (Exception exception)
{ {
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly"); logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
await TransitionMeetingAsync( return AssistantContextState.Error;
run,
AssistantContextState.Error,
CancellationToken.None);
} }
} }
@@ -1035,17 +1217,17 @@ public sealed class MeetingRecordingCoordinator
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords); : new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
} }
private MeetingAssistantOptions ResolveOptions(string? launchProfileName) private LaunchProfile ResolveProfile(string? launchProfileName)
{ {
if (launchProfiles is not null) if (launchProfiles is not null)
{ {
return launchProfiles.GetRequiredProfile(launchProfileName).Options; return launchProfiles.GetRequiredProfile(launchProfileName);
} }
if (string.IsNullOrWhiteSpace(launchProfileName) || if (string.IsNullOrWhiteSpace(launchProfileName) ||
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase)) launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
{ {
return options; return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
} }
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -1102,11 +1284,15 @@ public sealed class MeetingRecordingCoordinator
private int completed; private int completed;
private readonly object stateGate = new(); private readonly object stateGate = new();
private readonly object liveSegmentsGate = new(); private readonly object liveSegmentsGate = new();
private readonly object liveTranscriptTasksGate = new();
private readonly object liveIdentificationGate = new(); private readonly object liveIdentificationGate = new();
private readonly List<TranscriptionSegment> liveSegments = []; private readonly List<TranscriptionSegment> liveSegments = [];
private readonly List<Task> liveTranscriptTasks = [];
private readonly List<ISpeechRecognitionPipeline> pipelines = [];
private readonly ConcurrentDictionary<string, string> speakerMappings = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary<string, string> speakerMappings = new(StringComparer.OrdinalIgnoreCase);
private readonly SpeakerAudioSampleCollector speakerSampleCollector; private readonly SpeakerAudioSampleCollector speakerSampleCollector;
private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint; private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint;
private FinalSpeakerIdentificationWork? pendingFinalSpeakerIdentification;
public RecordingRun( public RecordingRun(
CancellationTokenSource captureCancellation, CancellationTokenSource captureCancellation,
@@ -1118,6 +1304,7 @@ public sealed class MeetingRecordingCoordinator
ISpeechRecognitionPipeline pipeline, ISpeechRecognitionPipeline pipeline,
SpeechRecognitionPipelineOptions pipelineOptions, SpeechRecognitionPipelineOptions pipelineOptions,
MeetingAssistantOptions options, MeetingAssistantOptions options,
string launchProfileName,
TimeSpan liveSampleBufferDuration, TimeSpan liveSampleBufferDuration,
int maxSpeakerSamples) int maxSpeakerSamples)
{ {
@@ -1131,6 +1318,8 @@ public sealed class MeetingRecordingCoordinator
Pipeline = pipeline; Pipeline = pipeline;
PipelineOptions = pipelineOptions; PipelineOptions = pipelineOptions;
Options = options; Options = options;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples); speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
} }
@@ -1148,11 +1337,15 @@ public sealed class MeetingRecordingCoordinator
public MeetingSessionArtifacts Artifacts { get; } public MeetingSessionArtifacts Artifacts { get; }
public ISpeechRecognitionPipeline Pipeline { get; } public SemaphoreSlim PipelineGate { get; } = new(1, 1);
public SpeechRecognitionPipelineOptions PipelineOptions { get; } public ISpeechRecognitionPipeline Pipeline { get; private set; }
public MeetingAssistantOptions Options { get; } public SpeechRecognitionPipelineOptions PipelineOptions { get; private set; }
public MeetingAssistantOptions Options { get; private set; }
public string LaunchProfileName { get; private set; }
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token; public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
@@ -1166,6 +1359,8 @@ public sealed class MeetingRecordingCoordinator
public bool IsAborted { get; private set; } public bool IsAborted { get; private set; }
public bool HasSwitchedProfile { get; private set; }
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata; public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
public void StopCapture() public void StopCapture()
@@ -1191,6 +1386,103 @@ public sealed class MeetingRecordingCoordinator
LiveIdentificationCancellationSource.Cancel(); LiveIdentificationCancellationSource.Cancel();
} }
public bool IsLaunchProfile(string launchProfileName)
{
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
}
public async ValueTask WriteAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
{
await PipelineGate.WaitAsync(cancellationToken);
try
{
AppendAudio(chunk);
await Pipeline.WriteAsync(chunk, cancellationToken);
}
finally
{
PipelineGate.Release();
}
}
public void ReplacePipeline(
ISpeechRecognitionPipeline pipeline,
SpeechRecognitionPipelineOptions pipelineOptions,
MeetingAssistantOptions options,
string launchProfileName)
{
Pipeline = pipeline;
PipelineOptions = pipelineOptions;
Options = options;
LaunchProfileName = launchProfileName;
pipelines.Add(pipeline);
}
public void AddLiveTranscriptTask(Task task)
{
lock (liveTranscriptTasksGate)
{
liveTranscriptTasks.Add(task);
}
}
public Task WaitForLiveTranscriptTasksAsync(CancellationToken cancellationToken)
{
Task[] tasks;
lock (liveTranscriptTasksGate)
{
tasks = liveTranscriptTasks.ToArray();
}
return Task.WhenAll(tasks).WaitAsync(cancellationToken);
}
public async Task DisposePipelinesAsync()
{
foreach (var pipeline in pipelines.Distinct())
{
await pipeline.DisposeAsync();
}
}
public void MarkProfileSwitched()
{
HasSwitchedProfile = true;
}
public void ResetSpeakerIdentification()
{
speakerMappings.Clear();
speakerSampleCollector.Reset();
lock (liveIdentificationGate)
{
lastLiveIdentificationCheckpoint = null;
}
}
public void SetPendingFinalSpeakerIdentification(
string audioPath,
IReadOnlyList<TranscriptionSegment> segments,
IReadOnlyList<SpeakerAudioSample> samples,
IReadOnlyDictionary<string, string> knownSpeakerMappings)
{
pendingFinalSpeakerIdentification = new FinalSpeakerIdentificationWork(
audioPath,
segments.ToList(),
samples.ToList(),
knownSpeakerMappings.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
public FinalSpeakerIdentificationWork? TakePendingFinalSpeakerIdentification()
{
var pending = pendingFinalSpeakerIdentification;
pendingFinalSpeakerIdentification = null;
return pending;
}
public bool TryTransitionTo( public bool TryTransitionTo(
AssistantContextState state, AssistantContextState state,
out AssistantContextState fromState) out AssistantContextState fromState)
@@ -1340,6 +1632,7 @@ public sealed class MeetingRecordingCoordinator
CaptureCancellationSource.Dispose(); CaptureCancellationSource.Dispose();
TranscriptionCancellationSource.Dispose(); TranscriptionCancellationSource.Dispose();
LiveIdentificationCancellationSource.Dispose(); LiveIdentificationCancellationSource.Dispose();
PipelineGate.Dispose();
} }
private static int StateRank(AssistantContextState state) private static int StateRank(AssistantContextState state)
@@ -1367,6 +1660,12 @@ public sealed class MeetingRecordingCoordinator
Speakers.SequenceEqual(other.Speakers, StringComparer.OrdinalIgnoreCase); Speakers.SequenceEqual(other.Speakers, StringComparer.OrdinalIgnoreCase);
} }
} }
public sealed record FinalSpeakerIdentificationWork(
string AudioPath,
IReadOnlyList<TranscriptionSegment> Segments,
IReadOnlyList<SpeakerAudioSample> Samples,
IReadOnlyDictionary<string, string> KnownSpeakerMappings);
} }
private sealed class EmptyDictationWordStore : IDictationWordStore private sealed class EmptyDictationWordStore : IDictationWordStore
@@ -31,6 +31,15 @@ internal sealed class RollingAudioBuffer
} }
} }
public void Reset()
{
lock (gate)
{
chunks.Clear();
position = TimeSpan.Zero;
}
}
public byte[] TryExtractWav(TimeSpan start, TimeSpan end) public byte[] TryExtractWav(TimeSpan start, TimeSpan end)
{ {
if (end <= start) if (end <= start)
@@ -21,6 +21,15 @@ internal sealed class SpeakerAudioSampleCollector
audioBuffer.Append(chunk); audioBuffer.Append(chunk);
} }
public void Reset()
{
lock (gate)
{
samplesBySpeaker.Clear();
audioBuffer.Reset();
}
}
public void TryAdd(TranscriptionSegment segment) public void TryAdd(TranscriptionSegment segment)
{ {
if (!IsDiarizedSpeaker(segment.Speaker)) if (!IsDiarizedSpeaker(segment.Speaker))
@@ -59,6 +59,29 @@ public interface ISpeakerIdentificationService
SpeakerIdentificationRequest request, SpeakerIdentificationRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return IdentifyKnownSpeakersAsync(request, cancellationToken);
}
Task ApplySpeakerOverrideAsync(
SpeakerIdentificationRequest request,
string sourceSpeaker,
string targetSpeaker,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
Task DeleteSpeakerIdentityAsync(
string identity,
CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync( Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
SpeakerIdentificationRequest request, SpeakerIdentificationRequest request,
CancellationToken cancellationToken); CancellationToken cancellationToken);
@@ -41,6 +41,94 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken); return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken);
} }
public async Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
SpeakerIdentificationRequest request,
CancellationToken cancellationToken)
{
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken);
}
public async Task ApplySpeakerOverrideAsync(
SpeakerIdentificationRequest request,
string sourceSpeaker,
string targetSpeaker,
CancellationToken cancellationToken)
{
if (!options.Enabled ||
string.IsNullOrWhiteSpace(sourceSpeaker) ||
string.IsNullOrWhiteSpace(targetSpeaker) ||
string.Equals(sourceSpeaker, targetSpeaker, StringComparison.OrdinalIgnoreCase))
{
return;
}
var sourceLabel = sourceSpeaker.Trim();
var targetName = targetSpeaker.Trim();
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
var now = DateTimeOffset.UtcNow;
var meetingReference = CreateReference(request.MeetingNote, now);
var snippet = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken);
var target = await FindIdentityByAcceptedNameAsync(context, targetName, cancellationToken);
var sourceCandidate = await FindCurrentRunCandidateAsync(
context,
meetingReference,
targetName,
cancellationToken);
if (target is null)
{
target = sourceCandidate ?? new SpeakerIdentity
{
CreatedAt = now,
CandidateNames = []
};
if (target.Id == 0)
{
context.SpeakerIdentities.Add(target);
}
}
else if (sourceCandidate is not null && sourceCandidate.Id != target.Id)
{
MergeOverrideCandidate(target, sourceCandidate);
context.SpeakerIdentities.Remove(sourceCandidate);
}
target.CanonicalName = targetName;
target.UpdatedAt = now;
ResetCandidates(target, [targetName]);
AddMeetingReference(target, meetingReference);
AddSnippetIfNeeded(target, snippet);
await context.SaveChangesAsync(cancellationToken);
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
target.References,
sourceLabel,
targetName,
cancellationToken);
}
public async Task DeleteSpeakerIdentityAsync(
string identity,
CancellationToken cancellationToken)
{
if (!options.Enabled || string.IsNullOrWhiteSpace(identity))
{
return;
}
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
var target = await FindIdentityByAcceptedNameAsync(context, identity.Trim(), cancellationToken);
if (target is null)
{
return;
}
context.SpeakerIdentities.Remove(target);
await context.SaveChangesAsync(cancellationToken);
}
private async Task<SpeakerIdentificationResult> ProcessTranscriptAsync( private async Task<SpeakerIdentificationResult> ProcessTranscriptAsync(
SpeakerIdentificationRequest request, SpeakerIdentificationRequest request,
SpeakerIdentityProcessingMode mode, SpeakerIdentityProcessingMode mode,
@@ -250,6 +338,115 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
return null; return null;
} }
private async Task<byte[]> ResolveOverrideSnippetAsync(
SpeakerIdentificationRequest request,
string sourceSpeaker,
CancellationToken cancellationToken)
{
var sample = request.Samples?
.Where(sample => string.Equals(sample.Speaker, sourceSpeaker, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(sample => sample.Score)
.FirstOrDefault();
if (sample is not null)
{
return sample.WavBytes;
}
var segments = request.Segments
.Where(segment => string.Equals(segment.Speaker, sourceSpeaker, StringComparison.OrdinalIgnoreCase))
.ToList();
return segments.Count == 0
? []
: await snippetExtractor.ExtractSnippetAsync(request.AudioPath, segments, cancellationToken);
}
private static async Task<SpeakerIdentity?> FindIdentityByAcceptedNameAsync(
SpeakerIdentityDbContext context,
string name,
CancellationToken cancellationToken)
{
var identities = await context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.Include(identity => identity.References)
.ToListAsync(cancellationToken);
return identities
.Where(identity => GetAcceptedNames(identity).Contains(name))
.OrderBy(identity => string.Equals(identity.CanonicalName, name, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(identity => identity.Id)
.FirstOrDefault();
}
private static async Task<SpeakerIdentity?> FindCurrentRunCandidateAsync(
SpeakerIdentityDbContext context,
SpeakerIdentityReference reference,
string targetName,
CancellationToken cancellationToken)
{
var candidates = await context.SpeakerIdentities
.Include(identity => identity.CandidateNames)
.Include(identity => identity.Aliases)
.Include(identity => identity.Snippets)
.Include(identity => identity.References)
.Where(identity => string.IsNullOrWhiteSpace(identity.CanonicalName))
.ToListAsync(cancellationToken);
return candidates
.Where(identity => identity.References.Any(existing => IsSameReference(existing, reference)))
.Where(identity => identity.CandidateNames.Any(candidate =>
string.Equals(candidate.Name, targetName, StringComparison.OrdinalIgnoreCase)) ||
identity.Aliases.Any(alias =>
string.Equals(alias.Name, targetName, StringComparison.OrdinalIgnoreCase)))
.OrderBy(identity => identity.Id)
.FirstOrDefault();
}
private void MergeOverrideCandidate(
SpeakerIdentity target,
SpeakerIdentity source)
{
AddAlias(target, source.CanonicalName);
foreach (var alias in source.Aliases)
{
AddAlias(target, alias.Name);
}
foreach (var candidate in source.CandidateNames)
{
AddAlias(target, candidate.Name);
}
foreach (var reference in source.References)
{
SpeakerIdentityReferences.AddIfMissing(target, reference);
}
foreach (var snippet in source.Snippets)
{
AddSnippetIfNeeded(target, snippet.WavBytes);
}
}
private static void AddAlias(SpeakerIdentity identity, string? alias)
{
if (string.IsNullOrWhiteSpace(alias) ||
string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) ||
identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase)))
{
return;
}
identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() });
}
private static bool IsSameReference(
SpeakerIdentityReference first,
SpeakerIdentityReference second)
{
return string.Equals(first.MeetingNotePath, second.MeetingNotePath, StringComparison.OrdinalIgnoreCase) &&
string.Equals(first.TranscriptPath, second.TranscriptPath, StringComparison.OrdinalIgnoreCase);
}
private static bool MatchesAcceptedNames( private static bool MatchesAcceptedNames(
SpeakerIdentity identity, SpeakerIdentity identity,
IReadOnlySet<string> names) IReadOnlySet<string> names)
@@ -0,0 +1,265 @@
using MeetingAssistant.MeetingNotes;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace MeetingAssistant.Speakers;
public sealed record SpeakerOverride(string From, string To, bool Merge = false);
public sealed record SpeakerIdentityDeletion(string Identity, string Replacement);
public static partial class SpeakerOverrideArtifacts
{
public static async Task ApplyToTranscriptAsync(
string transcriptPath,
SpeakerOverride speakerOverride,
CancellationToken cancellationToken = default)
{
if (!File.Exists(transcriptPath))
{
return;
}
await RewriteTranscriptSpeakerAsync(
transcriptPath,
speakerOverride.From,
speakerOverride.To,
cancellationToken);
}
public static async Task<bool> TranscriptContainsSpeakerAsync(
string transcriptPath,
string speaker,
CancellationToken cancellationToken = default)
{
if (!File.Exists(transcriptPath))
{
return false;
}
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
return TranscriptContainsSpeaker(content, speaker);
}
public static async Task AppendToAssistantContextAsync(
string assistantContextPath,
SpeakerOverride speakerOverride,
CancellationToken cancellationToken = default)
{
await AppendMarkerAsync(assistantContextPath, FormatMarker(speakerOverride), cancellationToken);
}
public static async Task<SpeakerIdentityDeletion> ApplyDeletionToTranscriptAsync(
string transcriptPath,
string identity,
CancellationToken cancellationToken = default)
{
var trimmedIdentity = identity.Trim();
var replacement = "Removed-1";
if (!File.Exists(transcriptPath))
{
return new SpeakerIdentityDeletion(trimmedIdentity, replacement);
}
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
replacement = NextRemovedSpeakerLabel(content);
await RewriteTranscriptSpeakerAsync(
transcriptPath,
trimmedIdentity,
replacement,
content,
cancellationToken);
return new SpeakerIdentityDeletion(trimmedIdentity, replacement);
}
public static async Task AppendIdentityDeletionToAssistantContextAsync(
string assistantContextPath,
SpeakerIdentityDeletion deletion,
CancellationToken cancellationToken = default)
{
await AppendMarkerAsync(assistantContextPath, FormatMarker(deletion), cancellationToken);
}
public static async Task<IReadOnlyList<SpeakerOverride>> ReadFromAssistantContextAsync(
string assistantContextPath,
CancellationToken cancellationToken = default)
{
if (!File.Exists(assistantContextPath))
{
return [];
}
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
var overrides = new List<SpeakerOverride>();
foreach (Match match in SpeakerOverrideMarkerRegex().Matches(content))
{
SpeakerOverrideJson? parsed;
try
{
parsed = JsonSerializer.Deserialize<SpeakerOverrideJson>(match.Groups["json"].Value);
}
catch (JsonException)
{
continue;
}
if (!string.IsNullOrWhiteSpace(parsed?.From) && !string.IsNullOrWhiteSpace(parsed.To))
{
overrides.Add(new SpeakerOverride(parsed.From.Trim(), parsed.To.Trim(), parsed.Merge));
}
}
return overrides
.DistinctBy(speakerOverride => $"{speakerOverride.From}\n{speakerOverride.To}\n{speakerOverride.Merge}", StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static async Task<IReadOnlyList<SpeakerIdentityDeletion>> ReadIdentityDeletionsFromAssistantContextAsync(
string assistantContextPath,
CancellationToken cancellationToken = default)
{
if (!File.Exists(assistantContextPath))
{
return [];
}
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
var deletions = new List<SpeakerIdentityDeletion>();
foreach (Match match in SpeakerIdentityDeletionMarkerRegex().Matches(content))
{
SpeakerIdentityDeletionJson? parsed;
try
{
parsed = JsonSerializer.Deserialize<SpeakerIdentityDeletionJson>(match.Groups["json"].Value);
}
catch (JsonException)
{
continue;
}
if (!string.IsNullOrWhiteSpace(parsed?.Identity) && !string.IsNullOrWhiteSpace(parsed.Replacement))
{
deletions.Add(new SpeakerIdentityDeletion(parsed.Identity.Trim(), parsed.Replacement.Trim()));
}
}
return deletions
.DistinctBy(deletion => $"{deletion.Identity}\n{deletion.Replacement}", StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string FormatMarker(SpeakerOverride speakerOverride)
{
var json = JsonSerializer.Serialize(new SpeakerOverrideJson(
speakerOverride.From,
speakerOverride.To,
speakerOverride.Merge));
return $"<!-- meeting-assistant:speaker-override {json} -->";
}
private static async Task<string> ReadExistingAssistantContextAsync(
string assistantContextPath,
CancellationToken cancellationToken)
{
if (!File.Exists(assistantContextPath))
{
throw new FileNotFoundException(
"Speaker identity markers can only be written to an existing assistant context note.",
assistantContextPath);
}
return await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
}
private static async Task RewriteTranscriptSpeakerAsync(
string transcriptPath,
string from,
string to,
CancellationToken cancellationToken)
{
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
await RewriteTranscriptSpeakerAsync(transcriptPath, from, to, content, cancellationToken);
}
private static async Task RewriteTranscriptSpeakerAsync(
string transcriptPath,
string from,
string to,
string content,
CancellationToken cancellationToken)
{
var pattern = $@"^(\[[^\]]+\]\s*){Regex.Escape(from)}:";
var updated = Regex.Replace(
content,
pattern,
match => $"{match.Groups[1].Value}{to}:",
RegexOptions.Multiline);
if (!string.Equals(content, updated, StringComparison.Ordinal))
{
await File.WriteAllTextAsync(transcriptPath, updated, cancellationToken);
}
}
private static async Task AppendMarkerAsync(
string assistantContextPath,
string marker,
CancellationToken cancellationToken)
{
var content = await ReadExistingAssistantContextAsync(assistantContextPath, cancellationToken);
if (content.Contains(marker, StringComparison.Ordinal))
{
return;
}
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content);
var updatedBody = body.TrimEnd('\r', '\n') +
(string.IsNullOrWhiteSpace(body) ? "" : Environment.NewLine + Environment.NewLine) +
marker +
Environment.NewLine;
var updated = string.IsNullOrWhiteSpace(frontmatter)
? updatedBody
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
await File.WriteAllTextAsync(assistantContextPath, updated, cancellationToken);
}
private static bool TranscriptContainsSpeaker(string content, string speaker)
{
var pattern = $@"^\[[^\]]+\]\s*{Regex.Escape(speaker.Trim())}:";
return Regex.IsMatch(content, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline);
}
private static string FormatMarker(SpeakerIdentityDeletion deletion)
{
var json = JsonSerializer.Serialize(new SpeakerIdentityDeletionJson(deletion.Identity, deletion.Replacement));
return $"<!-- meeting-assistant:speaker-identity-deletion {json} -->";
}
private static string NextRemovedSpeakerLabel(string content)
{
var next = RemovedSpeakerLabelRegex()
.Matches(content)
.Select(match => int.TryParse(match.Groups["number"].Value, out var number) ? number : 0)
.DefaultIfEmpty(0)
.Max() + 1;
return $"Removed-{next}";
}
[GeneratedRegex(@"<!--\s*meeting-assistant:speaker-override\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex SpeakerOverrideMarkerRegex();
[GeneratedRegex(@"<!--\s*meeting-assistant:speaker-identity-deletion\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex SpeakerIdentityDeletionMarkerRegex();
[GeneratedRegex(@"^\[[^\]]+\]\s*Removed-(?<number>\d+):", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex RemovedSpeakerLabelRegex();
private sealed record SpeakerOverrideJson(
[property: System.Text.Json.Serialization.JsonPropertyName("from")] string From,
[property: System.Text.Json.Serialization.JsonPropertyName("to")] string To,
[property: System.Text.Json.Serialization.JsonPropertyName("merge")] bool Merge = false);
private sealed record SpeakerIdentityDeletionJson(
[property: System.Text.Json.Serialization.JsonPropertyName("identity")] string Identity,
[property: System.Text.Json.Serialization.JsonPropertyName("replacement")] string Replacement);
}
@@ -21,6 +21,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
private readonly TimeSpan reconnectionDelay; private readonly TimeSpan reconnectionDelay;
private readonly LiteLlmResponsesCompactionOptions? compactionOptions; private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
private readonly ILogger? logger; private readonly ILogger? logger;
private int responsesRequestCount;
public LiteLlmResponsesChatClient( public LiteLlmResponsesChatClient(
Uri endpoint, Uri endpoint,
@@ -31,7 +32,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts, int reconnectionAttempts,
TimeSpan reconnectionDelay, TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null, LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null) ILogger? logger = null,
bool firstRequestIsUser = true)
: this( : this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) }, new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey, apiKey,
@@ -41,7 +43,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
reconnectionAttempts, reconnectionAttempts,
reconnectionDelay, reconnectionDelay,
compactionOptions, compactionOptions,
logger) logger,
firstRequestIsUser)
{ {
} }
@@ -54,7 +57,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts, int reconnectionAttempts,
TimeSpan reconnectionDelay, TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null, LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null) ILogger? logger = null,
bool firstRequestIsUser = true)
{ {
this.httpClient = httpClient; this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
@@ -65,6 +69,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero; this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero;
this.compactionOptions = compactionOptions; this.compactionOptions = compactionOptions;
this.logger = logger; this.logger = logger;
responsesRequestCount = firstRequestIsUser ? 0 : 1;
} }
public void Dispose() public void Dispose()
@@ -81,6 +86,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false); var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
var response = ParseResponseJson(responseJson); var response = ParseResponseJson(responseJson);
LogResponseDiagnostics(responseJson, response);
ThrowIfResponseHasNoContent(responseJson, response);
LogResponseUsage(response.Usage); LogResponseUsage(response.Usage);
return response; return response;
} }
@@ -225,12 +232,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
} }
}; };
using var content = new StringContent(request.ToJsonString(JsonOptions), Encoding.UTF8, "application/json"); using var requestMessage = CreateJsonRequest(
using var response = await httpClient.PostAsync( NormalizeRelativePath(compactionOptions.CompactPath),
NormalizeRelativePath(compactionOptions.CompactPath), request.ToJsonString(JsonOptions),
content, "agent");
cancellationToken) using var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
.ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
@@ -319,6 +325,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
}; };
} }
if (options?.MaxOutputTokens is > 0)
{
payload["max_output_tokens"] = options.MaxOutputTokens.Value;
}
var tools = CreateTools(options?.Tools); var tools = CreateTools(options?.Tools);
if (tools.Count > 0) if (tools.Count > 0)
{ {
@@ -338,8 +349,13 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
{ {
try try
{ {
using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); var initiator = NextInitiator();
using var response = await httpClient.PostAsync("responses", content, cancellationToken).ConfigureAwait(false); LogRequestDiagnostics(payload, initiator, attempt);
using var request = CreateJsonRequest(
"responses",
payloadJson,
initiator);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
@@ -367,6 +383,28 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed."); throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
} }
private HttpRequestMessage CreateJsonRequest(
string requestUri,
string payloadJson,
string? initiator = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new StringContent(payloadJson, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(initiator))
{
request.Headers.TryAddWithoutValidation("X-Initiator", initiator);
}
return request;
}
private string NextInitiator()
{
return Interlocked.Increment(ref responsesRequestCount) == 1 ? "user" : "agent";
}
private async Task DelayBeforeRetryAsync(CancellationToken cancellationToken) private async Task DelayBeforeRetryAsync(CancellationToken cancellationToken)
{ {
if (reconnectionDelay > TimeSpan.Zero) if (reconnectionDelay > TimeSpan.Zero)
@@ -423,6 +461,110 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
contextWindow); contextWindow);
} }
private void LogRequestDiagnostics(JsonObject payload, string initiator, int attempt)
{
if (logger is null)
{
return;
}
var inputCount = payload.TryGetPropertyValue("input", out var input) && input is JsonArray inputArray
? inputArray.Count
: 0;
var tools = payload.TryGetPropertyValue("tools", out var toolsNode) && toolsNode is JsonArray toolsArray
? toolsArray
: [];
var toolNames = tools
.OfType<JsonObject>()
.Select(tool => tool.TryGetPropertyValue("name", out var name) ? name?.GetValue<string>() : null)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
var instructionsPreview = payload.TryGetPropertyValue("instructions", out var instructionsNode)
? Truncate(instructionsNode?.GetValue<string>() ?? "", maxLength: 500)
: "";
logger.LogInformation(
"LiteLLM Responses request: initiator={Initiator}, attempt={Attempt}, inputItems={InputItems}, tools={ToolCount}, toolNames={ToolNames}, instructionsPreview={InstructionsPreview}",
initiator,
attempt + 1,
inputCount,
toolNames.Length,
string.Join(", ", toolNames),
instructionsPreview);
}
private void LogResponseDiagnostics(string responseJson, ChatResponse response)
{
if (logger is null)
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
var parsedTypes = response.Messages
.SelectMany(message => message.Contents)
.Select(content => content.GetType().Name)
.ToArray();
var functionCalls = response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionCallContent>()
.Select(call => call.Name)
.ToArray();
logger.LogInformation(
"LiteLLM Responses response: responseId={ResponseId}, outputTypes={OutputTypes}, parsedContents={ParsedContents}, functionCalls={FunctionCalls}, textLength={TextLength}",
response.ResponseId,
string.Join(", ", outputTypes),
string.Join(", ", parsedTypes),
string.Join(", ", functionCalls),
response.Text?.Length ?? 0);
if (parsedTypes.Length == 0)
{
logger.LogWarning(
"LiteLLM Responses response contained no parseable message text or function calls. Raw response preview: {ResponsePreview}",
Truncate(responseJson, maxLength: 6000));
}
}
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
{
if (response.Messages.SelectMany(message => message.Contents).Any())
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
throw new InvalidOperationException(
"LiteLLM Responses returned no parseable message text or function calls. " +
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
}
private static string[] GetOutputItemTypes(string responseJson)
{
try
{
using var document = JsonDocument.Parse(responseJson);
return document.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array
? output
.EnumerateArray()
.Select(item => GetString(item, "type") ?? item.ValueKind.ToString())
.ToArray()
: [];
}
catch (JsonException)
{
return ["<invalid-json>"];
}
}
private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength
? value
: value[..maxLength] + "...";
}
private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message) private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message)
{ {
var role = message.Role.Value; var role = message.Role.Value;
@@ -1,5 +1,6 @@
using MeetingAssistant.MeetingNotes; using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary; namespace MeetingAssistant.Summary;
@@ -12,14 +13,15 @@ public interface IMeetingSummaryArtifactResolver
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync( Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath, string summaryPath,
MeetingAssistantOptions options, MeetingAssistantOptions options,
CancellationToken cancellationToken) CancellationToken cancellationToken);
{
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
}
} }
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
{ {
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingAssistantOptions options; private readonly MeetingAssistantOptions options;
private readonly IMeetingNoteStore meetingNoteStore; private readonly IMeetingNoteStore meetingNoteStore;
@@ -49,6 +51,14 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
return null; return null;
} }
var summaryArtifacts = await ResolveFromSummaryFrontmatterAsync(
requestedSummaryPath,
cancellationToken);
if (summaryArtifacts is not null)
{
return summaryArtifacts;
}
var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder); var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
if (!Directory.Exists(meetingNotesFolder)) if (!Directory.Exists(meetingNotesFolder))
{ {
@@ -64,16 +74,77 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
continue; continue;
} }
return new MeetingSessionArtifacts( return TryCreateArtifactsFromLinks(
note.Path, note.Path,
ResolveNoteLink(note.Frontmatter.Transcript, note.Path), requestedSummaryPath,
ResolveNoteLink(note.Frontmatter.AssistantContext, note.Path), note.Frontmatter.Transcript,
requestedSummaryPath); note.Frontmatter.AssistantContext);
} }
return null; return null;
} }
private static async Task<MeetingSessionArtifacts?> ResolveFromSummaryFrontmatterAsync(
string requestedSummaryPath,
CancellationToken cancellationToken)
{
if (!File.Exists(requestedSummaryPath))
{
return null;
}
var content = await File.ReadAllTextAsync(requestedSummaryPath, cancellationToken);
var (frontmatter, _) = MeetingArtifactFrontmatterRenderer.Split(content);
if (string.IsNullOrWhiteSpace(frontmatter))
{
return null;
}
SummaryArtifactLinks? links;
try
{
links = YamlDeserializer.Deserialize<SummaryArtifactLinks>(frontmatter);
}
catch (YamlDotNet.Core.YamlException)
{
return null;
}
if (string.IsNullOrWhiteSpace(links?.Meeting) ||
string.IsNullOrWhiteSpace(links.Transcript) ||
string.IsNullOrWhiteSpace(links.AssistantContext))
{
return null;
}
return TryCreateArtifactsFromLinks(
requestedSummaryPath,
requestedSummaryPath,
links.Transcript,
links.AssistantContext,
links.Meeting);
}
private static MeetingSessionArtifacts? TryCreateArtifactsFromLinks(
string sourceNotePath,
string requestedSummaryPath,
string transcriptLink,
string assistantContextLink,
string? meetingLink = null)
{
var assistantContextPath = ResolveNoteLink(assistantContextLink, sourceNotePath);
if (string.IsNullOrWhiteSpace(assistantContextPath) || !File.Exists(assistantContextPath))
{
return null;
}
return new MeetingSessionArtifacts(
meetingLink is null ? sourceNotePath : ResolveNoteLink(meetingLink, sourceNotePath),
ResolveNoteLink(transcriptLink, sourceNotePath),
assistantContextPath,
requestedSummaryPath);
}
private static string? ResolveRequestedSummaryPath( private static string? ResolveRequestedSummaryPath(
string summaryPath, string summaryPath,
MeetingAssistantOptions options) MeetingAssistantOptions options)
@@ -141,4 +212,16 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
var normalizedPath = Path.GetFullPath(path); var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase); return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
} }
private sealed class SummaryArtifactLinks
{
[YamlMember(Alias = "meeting")]
public string? Meeting { get; set; }
[YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
}
} }
@@ -14,6 +14,9 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time. Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note. Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time. Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
Use add_attendee and remove_attendee to sharpen the meeting attendees list from clear transcript evidence and screenshot OCR participant evidence. Treat OCR that says visible people are a partial screenshot result as incomplete evidence; do not remove attendees solely because they are absent from a partial screenshot.
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript.
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context. After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files. Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
The summary note should contain concise sections for summary, decisions, open questions, and next steps. The summary note should contain concise sections for summary, decisions, open questions, and next steps.
@@ -0,0 +1,97 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryRetryRunner
{
Task<MeetingSummaryRetryTriggerResult?> TriggerAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed record MeetingSummaryRetryTriggerResult(
string SummaryPath,
string AssistantContextPath);
public sealed class MeetingSummaryRetryRunner : IMeetingSummaryRetryRunner
{
private readonly IMeetingSummaryArtifactResolver artifactResolver;
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly IMeetingArtifactStore artifactStore;
private readonly ILogger<MeetingSummaryRetryRunner> logger;
public MeetingSummaryRetryRunner(
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingArtifactStore artifactStore,
ILogger<MeetingSummaryRetryRunner> logger)
{
this.artifactResolver = artifactResolver;
this.summaryPipeline = summaryPipeline;
this.artifactStore = artifactStore;
this.logger = logger;
}
public async Task<MeetingSummaryRetryTriggerResult?> TriggerAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(
summaryPath,
options,
cancellationToken);
if (artifacts is null)
{
return null;
}
_ = Task.Run(() => RunRetryAsync(artifacts, options), CancellationToken.None);
return new MeetingSummaryRetryTriggerResult(
artifacts.SummaryPath,
artifacts.AssistantContextPath);
}
private async Task RunRetryAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options)
{
try
{
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
AssistantContextState.Summarizing,
CancellationToken.None);
var result = await summaryPipeline.RunAsync(
artifacts,
options,
CancellationToken.None);
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
CancellationToken.None);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Summary retry failed unexpectedly for {SummaryPath}",
artifacts.SummaryPath);
try
{
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
AssistantContextState.Error,
CancellationToken.None);
}
catch (Exception stateException)
{
logger.LogWarning(
stateException,
"Could not mark assistant context error after summary retry failed for {SummaryPath}",
artifacts.SummaryPath);
}
}
}
}
+179 -4
View File
@@ -1,4 +1,5 @@
using MeetingAssistant.MeetingNotes; using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription; using MeetingAssistant.Transcription;
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
@@ -94,6 +95,117 @@ public sealed class MeetingSummaryTools
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s)."; return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
} }
public async Task<string> AddAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var displayName = attendee.Trim();
var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
if (note.Frontmatter.Attendees.Any(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase)))
{
return $"Attendee {normalized} already exists.";
}
note.Frontmatter.Attendees.Add(displayName);
await WriteMeetingNoteAsync(note);
return $"Added attendee {displayName}.";
}
public async Task<string> RemoveAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var normalized = MeetingAttendeeNames.NormalizeDisplayName(attendee);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
var removed = note.Frontmatter.Attendees.RemoveAll(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return $"Attendee {normalized} was not present.";
}
await WriteMeetingNoteAsync(note);
return $"Removed attendee {normalized}.";
}
public async Task<string> OverrideSpeaker(string speaker, string replacement, bool merge = false)
{
if (string.IsNullOrWhiteSpace(speaker) || string.IsNullOrWhiteSpace(replacement))
{
return "Refused: speaker and replacement must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var speakerOverride = new SpeakerOverride(speaker.Trim(), replacement.Trim(), merge);
if (string.Equals(speakerOverride.From, speakerOverride.To, StringComparison.OrdinalIgnoreCase))
{
return "Refused: speaker and replacement must be different.";
}
if (!merge && await SpeakerOverrideArtifacts.TranscriptContainsSpeakerAsync(
artifacts.TranscriptPath,
speakerOverride.To,
CancellationToken.None))
{
return $"Refused: transcript already contains speaker {speakerOverride.To}. Call override_speaker with merge=true only when you are certain both speakers are the same identity.";
}
await SpeakerOverrideArtifacts.ApplyToTranscriptAsync(artifacts.TranscriptPath, speakerOverride);
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(artifacts.AssistantContextPath, speakerOverride);
return merge
? $"Merged speaker {speakerOverride.From} into {speakerOverride.To}."
: $"Overrode speaker {speakerOverride.From} with {speakerOverride.To}.";
}
public async Task<string> DeleteIdentity(string identity)
{
if (string.IsNullOrWhiteSpace(identity))
{
return "Refused: identity must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
artifacts.TranscriptPath,
identity,
CancellationToken.None);
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
artifacts.AssistantContextPath,
deletion,
CancellationToken.None);
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
}
public async Task<string> WriteSummary(string markdown, string? title = null) public async Task<string> WriteSummary(string markdown, string? title = null)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!); Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
@@ -109,9 +221,12 @@ public sealed class MeetingSummaryTools
await File.WriteAllTextAsync( await File.WriteAllTextAsync(
artifacts.SummaryPath, artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown)); MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
SummaryWasWritten = true;
return artifacts.SummaryPath; return artifacts.SummaryPath;
} }
public bool SummaryWasWritten { get; private set; }
public async Task<string> WriteContext( public async Task<string> WriteContext(
string content, string content,
int? @from = null, int? @from = null,
@@ -124,10 +239,12 @@ public sealed class MeetingSummaryTools
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite."; return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
} }
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!); if (!File.Exists(artifacts.AssistantContextPath))
var existing = File.Exists(artifacts.AssistantContextPath) {
? await File.ReadAllTextAsync(artifacts.AssistantContextPath) return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
: ""; }
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing); var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode); var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter) var updated = string.IsNullOrWhiteSpace(frontmatter)
@@ -267,6 +384,64 @@ public sealed class MeetingSummaryTools
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes()); return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
} }
private async Task WriteMeetingNoteAsync(MeetingNote note)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
RenderMeetingNote(note.Frontmatter, note.UserNotes));
}
private static string RenderMeetingNote(MeetingNoteFrontmatter frontmatter, string userNotes)
{
var lines = new List<string>
{
"---",
$"title: {EscapeScalar(frontmatter.Title)}",
$"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}",
$"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}",
"attendees:"
};
lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}"));
lines.Add("projects:");
lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}"));
lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}");
lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}");
lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}");
lines.Add("---");
lines.Add("");
lines.Add(userNotes);
return string.Join(Environment.NewLine, lines);
}
private static string EscapeScalar(string value)
{
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
}
private static string EscapeQuoted(string value)
{
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
}
private static string EscapeNullableDateTime(DateTimeOffset? value)
{
return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"));
}
private static string EscapeListItem(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "\"\"";
}
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
? EscapeQuoted(value)
: value;
}
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects) private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
{ {
var boundProjects = await GetBoundProjectsAsync(); var boundProjects = await GetBoundProjectsAsync();
@@ -51,7 +51,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
var agentOptions = options.Agent; var agentOptions = options.Agent;
var key = ResolveApiKey(agentOptions); var key = ResolveApiKey(agentOptions);
var writeAudit = new SummaryAgentWriteAudit(artifacts); var writeAudit = new SummaryAgentWriteAudit(artifacts);
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit)); var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit);
var tools = CreateTools(meetingTools);
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken); var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
using var compactionSummaryClient = new LiteLlmResponsesChatClient( using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint), new Uri(agentOptions.Endpoint),
@@ -62,7 +63,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
agentOptions.ReconnectionAttempts, agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay, agentOptions.ReconnectionDelay,
compactionOptions: null, compactionOptions: null,
logger); logger,
firstRequestIsUser: false);
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient); var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
using var chatClient = new LiteLlmResponsesChatClient( using var chatClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint), new Uri(agentOptions.Endpoint),
@@ -73,7 +75,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
agentOptions.ReconnectionAttempts, agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay, agentOptions.ReconnectionDelay,
compactionOptions, compactionOptions,
logger); logger,
firstRequestIsUser: false);
var agent = chatClient var agent = chatClient
.AsBuilder() .AsBuilder()
.UseFunctionInvocation() .UseFunctionInvocation()
@@ -93,6 +96,10 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
session: null, session: null,
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)), options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
cancellationToken: cancellationToken); cancellationToken: cancellationToken);
if (!meetingTools.SummaryWasWritten)
{
throw CreateMissingSummaryWriteException(response);
}
await writeAudit.AppendToAssistantContextAsync(cancellationToken); await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text); return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
@@ -145,6 +152,22 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
tools.AddDictationWord, tools.AddDictationWord,
"add_dictation_word", "add_dictation_word",
"Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."), "Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."),
AIFunctionFactory.Create(
tools.AddAttendee,
"add_attendee",
"Add one attendee to the current meeting note frontmatter when transcript or OCR evidence clearly shows they attended."),
AIFunctionFactory.Create(
tools.RemoveAttendee,
"remove_attendee",
"Remove one attendee from the current meeting note frontmatter when evidence clearly shows they did not attend. Do not remove attendees solely because a screenshot contains only partial participant evidence."),
AIFunctionFactory.Create(
tools.OverrideSpeaker,
"override_speaker",
"Replace a transcript speaker label with a named speaker only when the evidence is very certain. If the replacement speaker already exists, set merge=true only when both labels are certainly the same identity."),
AIFunctionFactory.Create(
tools.DeleteIdentity,
"delete_identity",
"Remove a wrongfully matched speaker identity and relabel transcript mentions to Removed-n only when the evidence is certain."),
AIFunctionFactory.Create( AIFunctionFactory.Create(
tools.WriteSummary, tools.WriteSummary,
"write_summary", "write_summary",
@@ -172,11 +195,21 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
]; ];
} }
private static InvalidOperationException CreateMissingSummaryWriteException(AgentResponse response)
{
var message = string.IsNullOrWhiteSpace(response.Text)
? "Summary agent completed without calling write_summary."
: $"Summary agent completed without calling write_summary. Response: {response.Text}";
return new InvalidOperationException(message);
}
private static ChatOptions CreateChatOptions(AgentOptions options) private static ChatOptions CreateChatOptions(AgentOptions options)
{ {
var chatOptions = new ChatOptions var chatOptions = new ChatOptions
{ {
ModelId = options.Model, ModelId = options.Model,
MaxOutputTokens = options.MaxOutputTokens,
AllowMultipleToolCalls = true, AllowMultipleToolCalls = true,
ToolMode = ChatToolMode.Auto ToolMode = ChatToolMode.Auto
}; };
@@ -60,10 +60,12 @@ public sealed class SummaryAgentWriteAudit
return; return;
} }
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!); if (!File.Exists(artifacts.AssistantContextPath))
var existing = File.Exists(artifacts.AssistantContextPath) {
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken) return;
: ""; }
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
var builder = new StringBuilder(); var builder = new StringBuilder();
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal)) if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
{ {
+2 -2
View File
@@ -2,7 +2,7 @@
"MeetingAssistant": { "MeetingAssistant": {
"Hotkey": { "Hotkey": {
"Toggle": "Ctrl+Alt+M", "Toggle": "Ctrl+Alt+M",
"Abort": "Ctrl+Alt+D" "Abort": "Ctrl+Alt+Z"
}, },
"Vault": { "Vault": {
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta", "BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
@@ -125,7 +125,7 @@
"Agent": { "Agent": {
"Endpoint": "https://litellm.schweigert.cloud", "Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY", "KeyEnv": "LITELLM_API_KEY",
"Model": "copilot-gpt-5.5", "Model": "chatgpt/gpt-5.5",
"EnableThinking": true, "EnableThinking": true,
"ReasoningEffort": "Medium", "ReasoningEffort": "Medium",
"ReconnectionAttempts": 2, "ReconnectionAttempts": 2,
+12 -5
View File
@@ -9,6 +9,7 @@ The application is intended to:
- create an Obsidian markdown note before transcription starts - create an Obsidian markdown note before transcription starts
- keep meeting metadata, user notes, detected context, and links to generated output in that note - keep meeting metadata, user notes, detected context, and links to generated output in that note
- toggle recording/transcription mode through a configurable global hotkey - toggle recording/transcription mode through a configurable global hotkey
- switch the active transcription profile during a meeting with a profile-specific toggle hotkey
- abort and discard an active recording through a configurable global hotkey - abort and discard an active recording through a configurable global hotkey
- capture active-window screenshots through a configurable global hotkey - capture active-window screenshots through a configurable global hotkey
- capture microphone input and computer output into one transcription stream - capture microphone input and computer output into one transcription stream
@@ -87,7 +88,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"MeetingAssistant": { "MeetingAssistant": {
"Hotkey": { "Hotkey": {
"Toggle": "Ctrl+Alt+M", "Toggle": "Ctrl+Alt+M",
"Abort": "Ctrl+Alt+D" "Abort": "Ctrl+Alt+Z"
}, },
"Vault": { "Vault": {
"TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts", "TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts",
@@ -184,7 +185,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
"Agent": { "Agent": {
"Endpoint": "https://litellm.schweigert.cloud", "Endpoint": "https://litellm.schweigert.cloud",
"KeyEnv": "LITELLM_API_KEY", "KeyEnv": "LITELLM_API_KEY",
"Model": "copilot-gpt-5.5", "Model": "chatgpt/gpt-5.5",
"EnableThinking": true, "EnableThinking": true,
"ReasoningEffort": "Medium", "ReasoningEffort": "Medium",
"ReconnectionAttempts": 2, "ReconnectionAttempts": 2,
@@ -222,7 +223,9 @@ For real meeting recordings, Meeting Assistant derives the optional finished-tra
Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using the configured vault folders. Meeting notes link to separate transcript, assistant context, and summary markdown artifacts using the configured vault folders.
`Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+D`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation. `Hotkey:Abort` configures a global discard shortcut. The default is `Ctrl+Alt+Z`. Aborting an active recording stops capture/transcription, deletes the meeting note, transcript, assistant context, summary file if present, and linked screenshot attachments for that run, and skips automatic summary generation.
Launch profile toggle hotkeys start a meeting when idle. When a different launch profile toggle is pressed during an active meeting, Meeting Assistant keeps the same meeting note, transcript, assistant context, and metadata; drains the current speech recognition pipeline without summarizing; appends a transcript marker; clears run-local speaker mappings; and continues transcription through the target profile. Pressing the same active profile toggle still stops the meeting normally.
Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary. Meeting note, transcript, assistant context, and summary artifacts use frontmatter links so they backlink to each other. Meeting note frontmatter includes `start_time` when recording starts and `end_time` when transcription processing finishes. Transcript and summary frontmatter copy the meeting title, start time, and end time from the meeting note; if the meeting has no title, the summary agent can provide one when writing the summary.
@@ -240,9 +243,9 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va
`Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. Launch profiles can override the screenshot hotkey with `LaunchProfiles:{profile}:Screenshots:Hotkey`; only explicitly configured profile hotkeys are registered, so profile-specific hotkeys do not silently inherit and collide with the default profile. `Screenshots:Hotkey` configures a global hotkey that captures the currently active window during an active meeting. Screenshots are written under `Screenshots:AttachmentsFolder`, which defaults to an `Attachments` folder beside the assistant context note, and each capture appends a timestamped markdown image link to the assistant context. Launch profiles can override the screenshot hotkey with `LaunchProfiles:{profile}:Screenshots:Hotkey`; only explicitly configured profile hotkeys are registered, so profile-specific hotkeys do not silently inherit and collide with the default profile.
`Screenshots:Ocr` optionally enables vision extraction for screenshots. When `Enabled` is true, Meeting Assistant sends the screenshot and prompt to an OpenAI-compatible Responses endpoint and replaces the screenshot OCR placeholder with the model output. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model, which keeps local OCR on the same LiteLLM backend as summarization. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. The default prompt asks the model to return pixel crop coordinates when it can confidently isolate only the presentation or shared-screen content; valid crop boxes are saved as `*-cropped.png` beside the original screenshot and linked before the OCR block. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. `Screenshots:Ocr` optionally enables vision extraction for screenshots. When `Enabled` is true, Meeting Assistant sends the screenshot and prompt to an OpenAI-compatible Responses endpoint and replaces the screenshot OCR placeholder with the model output. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model, which keeps local OCR on the same LiteLLM backend as summarization. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. The default prompt asks the model to say whether visible participants are clearly the exact meeting participants or only a partial result, and to return pixel crop coordinates when it can confidently isolate only the presentation or shared-screen content; valid crop boxes are saved as `*-cropped.png` beside the original screenshot and linked before the OCR block. Automatic summarization waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`.
`Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts. `Agent` configures the Microsoft Agent Framework summary pipeline. `Key` may be set directly for local testing, but `KeyEnv` is preferred; by default the API key is read from `LITELLM_API_KEY`. After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear; partial participant evidence from screenshots should not be used to remove invitees by itself. It can also override a transcript speaker label with a named speaker only when the evidence is very certain, such as a user note, OCR at a correlating meeting timestamp, or a clear context cue. If that named speaker already exists in the transcript, `override_speaker` refuses by default; the agent must pass `merge=true` only when it is certain both labels are the same identity. When it is certain that a speaker identity was wrongfully matched, it can delete that identity, relabeling transcript speaker labels to `Removed-<n>` and removing the local speaker identity database row before future matching. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes. `ReconnectionAttempts` and `ReconnectionDelay` control retries for transient model endpoint failures such as LiteLLM token refresh or 5xx responses. If summary generation still fails, Meeting Assistant overwrites the configured summary note with a markdown failure report containing the exception details and linked meeting artifacts.
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget. `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left. It first attempts `POST /v1/{ResponsesCompactPath}` on the configured OpenAI-compatible endpoint. If that endpoint is unavailable or returns invalid data, it falls back to Microsoft Agent Framework compaction: collapse old tool results, summarize older message groups, preserve only the last four user turns if needed, and finally drop oldest groups until the request is back under budget.
@@ -269,6 +272,10 @@ When assistant context contains cropped screenshot links produced by OCR, the su
- `write_projectfile` - `write_projectfile`
- `search` - `search`
- `write_context` - `write_context`
- `add_attendee`
- `remove_attendee`
- `override_speaker`
- `delete_identity`
`search` accepts ripgrep syntax and returns matches as `filename:line text`. If one project is searched, paths are relative to that project; if multiple projects are searched, paths include the project folder name. `search` accepts ripgrep syntax and returns matches as `filename:line text`. If one project is searched, paths are relative to that project; if multiple projects are searched, paths include the project folder name.
@@ -0,0 +1,14 @@
# Change: Refine attendees from screenshot OCR during summary
## Why
Calendar metadata often contains invited people rather than actual participants. Screenshots can reveal visible meeting participants, but OCR needs to state whether the visible people are complete or only partial evidence. The summary agent should be able to use that evidence to adjust the meeting attendee list before final speaker identity candidate updates run.
## What Changes
- The default screenshot OCR prompt asks the vision model to say whether visible people are the exact meeting participant set or only a partial result.
- The summary agent has scoped tools to add and remove attendees from the current meeting note.
- Summary instructions tell the agent to use transcript and OCR evidence to sharpen meeting attendees conservatively.
- Final speaker identity learning/candidate creation runs after summary generation so it uses the summary-refined meeting attendees.
## Impact
- Affects screenshot OCR prompt defaults, summary-agent tool surface, summary instructions, recording finalization order, and speaker identity candidate timing.
- Adds behavior tests for OCR instructions, summary attendee tools, and final speaker processing after summary.
@@ -0,0 +1,82 @@
## MODIFIED Requirements
### Requirement: Meeting Assistant generates meeting outputs
Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context.
The summary pipeline SHALL expose tools scoped to the current meeting:
- `read_meetingnote`
- `read_transcript`
- `read_context`
- `read_usernotes`
- `read_glossary`
- `add_dictation_word`
- `add_attendee`
- `remove_attendee`
- `override_speaker`
- `delete_identity`
- `write_summary`
- `write_context`
- `list_projects`
- `list_projectfiles`
- `read_projectfile`
- `write_projectfile`
- `search`
The summary instructions SHALL tell the summary agent to use transcript evidence and screenshot OCR participant evidence to refine the meeting note attendee list conservatively, adding or removing attendees only when the evidence is clear.
The summary instructions SHALL tell the summary agent to call `override_speaker` only when it is very certain that a transcript speaker label belongs to a named speaker, such as from user notes, OCR with a correlating meeting timestamp, or very clear context cues.
When `override_speaker` is called with an existing transcript speaker label and a replacement speaker name, Meeting Assistant SHALL rewrite that speaker label in the transcript and record the override for final speaker identity processing.
When `override_speaker` is called without merge enabled and the transcript already contains the replacement speaker name as a speaker label, Meeting Assistant SHALL refuse the override. When merge is enabled, Meeting Assistant SHALL allow the override, rewrite the source speaker label to the replacement speaker name, and record the override as a speaker merge for final speaker identity processing.
The summary instructions SHALL tell the summary agent to call `delete_identity` only when it is certain that an existing speaker identity was wrongfully matched.
When `delete_identity` is called with an existing transcript speaker identity, Meeting Assistant SHALL rewrite transcript speaker labels for that identity to `Removed-<n>` and record the deletion for final speaker identity processing.
#### Scenario: Summary agent refines attendees from clear participant evidence
- **GIVEN** assistant context OCR states that visible meeting participants are complete
- **AND** the OCR result names a participant missing from the meeting note
- **WHEN** the summary pipeline runs
- **THEN** the summary agent can add that attendee to the meeting note through `add_attendee`
#### Scenario: Summary agent does not over-trim partial participant evidence
- **GIVEN** assistant context OCR states that visible meeting participants are only a partial result
- **WHEN** the summary pipeline runs
- **THEN** the summary instructions tell the agent not to remove attendees solely because they are absent from that partial screenshot evidence
#### Scenario: Summary agent overrides a certain speaker label
- **GIVEN** the transcript contains speaker label `Guest-01`
- **AND** user notes or OCR evidence clearly identifies that speaker as `Sabrina`
- **WHEN** the summary agent calls `override_speaker` with `Guest-01` and `Sabrina`
- **THEN** Meeting Assistant rewrites the transcript speaker label to `Sabrina`
- **AND** records the override for final speaker identity processing
#### Scenario: Summary agent cannot accidentally merge speakers
- **GIVEN** the transcript contains speaker labels `Guest-01` and `Sabrina`
- **WHEN** the summary agent calls `override_speaker` with `Guest-01` and `Sabrina` without merge enabled
- **THEN** Meeting Assistant refuses the override
- **AND** keeps the transcript unchanged
#### Scenario: Summary agent explicitly merges speakers
- **GIVEN** the transcript contains speaker labels `Guest-01` and `Sabrina`
- **AND** user notes or OCR evidence clearly proves they are the same person
- **WHEN** the summary agent calls `override_speaker` with `Guest-01`, `Sabrina`, and merge enabled
- **THEN** Meeting Assistant rewrites `Guest-01` transcript speaker labels to `Sabrina`
- **AND** records the override as a merge for final speaker identity processing
#### Scenario: Summary agent deletes a wrong speaker identity
- **GIVEN** the transcript contains speaker identity `Sabrina`
- **AND** user notes or OCR evidence clearly proves that `Sabrina` was wrongfully matched
- **WHEN** the summary agent calls `delete_identity` with `Sabrina`
- **THEN** Meeting Assistant rewrites the transcript speaker label to `Removed-1`
- **AND** records the deletion for final speaker identity processing
### Requirement: Meeting screenshots are captured into assistant context
Meeting Assistant SHALL expose a configurable screenshot hotkey.
The default OCR prompt SHALL explain that the image is from a meeting and ask the model to identify who is talking, who is presenting, what is presented, capture slide text in markdown, convert diagrams to Mermaid when possible, indicate whether visible people are clearly the exact meeting participants or only a partial result, return crop coordinates only for confidently isolated presentation/shared-screen content, and otherwise describe the scene.
#### Scenario: OCR reports whether visible people are complete or partial
- **WHEN** Meeting Assistant uses the built-in screenshot OCR prompt
- **THEN** the prompt asks the model to state whether the screenshot clearly shows exactly who is in the meeting or only a partial participant result
@@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: Meeting Assistant learns speaker identities locally
Meeting Assistant SHALL maintain a local SQLite speaker identity database in the user's application data folder.
Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished and after automatic summary generation has completed, using the latest meeting note frontmatter.
When the summary agent records a speaker override from a diarized transcript label to a named speaker, final speaker identity processing SHALL attach the current run speaker sample to an existing identity with that name when one exists, or create a new canonical speaker identity with that name when none exists.
When a speaker override maps a current-run unnamed candidate to an existing named identity, Meeting Assistant SHALL merge the candidate's meeting reference and useful snippets into the named identity instead of leaving a duplicate candidate.
When the summary agent records that a speaker identity was wrongfully matched, final speaker identity processing SHALL delete the matching identity from the local speaker identity database so it cannot be matched again unless it is newly created in the future.
#### Scenario: Final speaker identity learning uses summary-refined attendees
- **GIVEN** the summary agent changes meeting note attendees during automatic summary generation
- **WHEN** Meeting Assistant performs final speaker identity learning and candidate creation
- **THEN** it uses the attendee list from the meeting note after the summary agent changes
#### Scenario: Speaker override attaches to existing identity
- **GIVEN** the speaker identity database contains canonical speaker `Sabrina`
- **AND** the summary agent records that transcript speaker `Guest-01` is `Sabrina`
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant stores the meeting reference and current speaker sample on Sabrina's identity
- **AND** does not create a separate unnamed candidate for `Guest-01`
#### Scenario: Speaker override creates named identity
- **GIVEN** the speaker identity database has no accepted name `Sabrina`
- **AND** the summary agent records that transcript speaker `Guest-01` is `Sabrina`
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant creates a canonical speaker identity named `Sabrina`
- **AND** stores the meeting reference and current speaker sample on that identity
#### Scenario: Speaker identity deletion removes a wrong match
- **GIVEN** the speaker identity database contains canonical speaker `Sabrina`
- **AND** the summary agent records that `Sabrina` was wrongfully matched
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant removes Sabrina's identity from the speaker identity database
- **AND** the relabeled transcript uses `Removed-1` instead of `Sabrina`
@@ -0,0 +1,27 @@
## 1. Implementation
- [x] 1.1 Add OpenSpec requirements for OCR participant certainty, summary attendee tools, and speaker identity timing.
- [x] 1.2 Add failing behavior tests for the default OCR prompt and summary attendee tool registration/behavior.
- [x] 1.3 Add failing behavior test proving final speaker identity processing sees attendees changed by summary.
- [x] 1.4 Implement OCR prompt and summary instructions/tool changes.
- [x] 1.5 Move final speaker identity learning/candidate update after summary completion without regressing transcript relabeling.
- [x] 1.6 Update README and run focused/full tests plus `openspec validate refine-attendees-from-ocr-summary --strict`.
## 2. Speaker overrides
- [x] 2.1 Add behavior tests for the summary `override_speaker` tool rewriting transcript labels and recording an override.
- [x] 2.2 Add behavior tests for final speaker identity processing attaching an override to an existing named identity or creating one.
- [x] 2.3 Implement the summary tool, instructions, and agent registration.
- [x] 2.4 Apply recorded overrides during post-summary final speaker identity processing.
- [x] 2.5 Update README and rerun focused/full tests plus `openspec validate refine-attendees-from-ocr-summary --strict`.
## 3. Speaker identity deletion
- [x] 3.1 Add behavior tests for the summary `delete_identity` tool rewriting transcript labels and recording a deletion.
- [x] 3.2 Add behavior tests for final speaker identity processing deleting the matching database identity and preserving the removed transcript label.
- [x] 3.3 Implement the summary tool, instructions, and agent registration.
- [x] 3.4 Apply recorded identity deletions during post-summary final speaker identity processing.
- [x] 3.5 Update README and rerun focused/full tests plus `openspec validate refine-attendees-from-ocr-summary --strict`.
## 4. Speaker override merge guard
- [x] 4.1 Add behavior tests for `override_speaker` refusing duplicate target speaker labels unless merge is enabled.
- [x] 4.2 Add behavior tests for `override_speaker` merge mode rewriting the transcript and recording the merge intent.
- [x] 4.3 Implement the merge parameter, duplicate target guard, instructions, and agent registration description.
- [x] 4.4 Update README and rerun focused/full tests plus `openspec validate refine-attendees-from-ocr-summary --strict`.
@@ -0,0 +1,14 @@
# Change: Switch transcription profile during an active meeting
## Why
Meetings can change spoken language or transcription requirements without being separate meetings. The user needs to switch from one launch profile to another during an active recording, for example from German to English, without creating new meeting artifacts or prematurely starting summarization.
## What Changes
- Profile hotkeys pressed during active recording switch the active meeting to that profile instead of stopping and summarizing the meeting.
- Switching keeps the current meeting note, transcript, assistant context, summary path, and metadata.
- Switching stops and drains the current speech recognition pipeline, buffers captured audio while the old pipeline drains, inserts a transcript marker, clears run-local speaker label mappings, and starts a new speech recognition pipeline using the requested profile options.
- The final stop after one or more switches still summarizes once for the same meeting.
## Impact
- Affects recording coordinator lifecycle, launch-profile hotkey semantics, live transcription buffering, speaker mapping lifecycle, and transcript output.
- Adds behavior tests for profile switching without metadata recollection or new artifacts.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Active recording can switch launch profiles
Meeting Assistant SHALL allow a launch profile hotkey or named-profile recording toggle request to switch the active meeting to that profile while recording capture is active.
When switching profiles during an active meeting, Meeting Assistant SHALL keep the existing meeting note, transcript, assistant context, summary path, user notes, calendar metadata, projects, attendees, and assistant-context state.
When switching profiles during an active meeting, Meeting Assistant SHALL NOT create new meeting artifacts, SHALL NOT collect calendar metadata again, and SHALL NOT run the summary pipeline for the old profile segment.
When switching profiles during an active meeting, Meeting Assistant SHALL stop and drain the current speech recognition pipeline, append a transcript marker indicating the target profile, start a new speech recognition pipeline using the target profile's resolved options, and continue writing live transcription to the same transcript file.
When audio is captured while the old speech recognition pipeline is draining, Meeting Assistant SHALL buffer that audio and send it to the new profile's speech recognition pipeline after it is ready.
When switching profiles during an active meeting, Meeting Assistant SHALL clear run-local speaker label mappings for future transcript segments because backend speaker IDs can change across speech recognition processes. Already-written named transcript segments SHALL remain unchanged.
#### Scenario: Active meeting switches to named profile
- **GIVEN** a meeting was started with the default launch profile
- **WHEN** the user triggers launch profile `english` while recording is active
- **THEN** Meeting Assistant keeps the existing meeting artifacts
- **AND** drains the old speech recognition pipeline without running summary generation
- **AND** appends a transcript marker for the profile switch
- **AND** starts transcription with the resolved `english` profile
- **AND** continues writing transcript segments to the same transcript file
#### Scenario: Captured audio is buffered while switching
- **GIVEN** a meeting is switching from one launch profile to another
- **WHEN** audio chunks are captured before the new speech recognition pipeline is ready
- **THEN** Meeting Assistant buffers those chunks
- **AND** sends the buffered chunks to the new speech recognition pipeline after it starts
#### Scenario: Switching clears future speaker mappings only
- **GIVEN** a live speaker label was mapped to a named speaker before switching profiles
- **WHEN** Meeting Assistant switches to another launch profile
- **THEN** already-written transcript text keeps the named speaker
- **AND** later transcript segments from the new pipeline do not reuse the old backend speaker label mapping
@@ -0,0 +1,11 @@
## ADDED Requirements
### Requirement: Speech recognition can be restarted within one meeting
Meeting Assistant SHALL support replacing the active speech recognition pipeline for a recording run while preserving the run's meeting artifacts and transcript session.
The replacement speech recognition pipeline SHALL use the target launch profile's resolved transcription options.
#### Scenario: Replacement pipeline uses target profile options
- **GIVEN** a recording run started with one launch profile
- **WHEN** the active transcription profile is switched to another launch profile
- **THEN** the new speech recognition pipeline is created from the target profile options
- **AND** it receives audio captured after the switch request and audio buffered during the switch
@@ -0,0 +1,8 @@
## 1. Implementation
- [x] 1.1 Add behavior tests for switching from default to named launch profile during an active recording.
- [x] 1.2 Keep existing meeting artifacts and skip metadata recollection when switching profile.
- [x] 1.3 Drain the old speech recognition pipeline without moving to summarizing.
- [x] 1.4 Buffer captured audio during the switch and feed it to the new profile pipeline.
- [x] 1.5 Append a transcript marker when the profile changes.
- [x] 1.6 Clear run-local speaker label mappings for the new transcription process.
- [x] 1.7 Update README and run focused/full tests plus `openspec validate switch-transcription-profile --strict`.
+34
View File
@@ -107,3 +107,37 @@ Meeting Assistant SHALL use the selected launch profile's summary-agent settings
- **GIVEN** two launch profiles configure the same toggle hotkey - **GIVEN** two launch profiles configure the same toggle hotkey
- **WHEN** Meeting Assistant resolves launch profiles - **WHEN** Meeting Assistant resolves launch profiles
- **THEN** Meeting Assistant rejects the configuration before registering global hotkeys - **THEN** Meeting Assistant rejects the configuration before registering global hotkeys
### Requirement: Active recording can switch launch profiles
Meeting Assistant SHALL allow a launch profile hotkey or named-profile recording toggle request to switch the active meeting to that profile while recording capture is active.
When switching profiles during an active meeting, Meeting Assistant SHALL keep the existing meeting note, transcript, assistant context, summary path, user notes, calendar metadata, projects, attendees, and assistant-context state.
When switching profiles during an active meeting, Meeting Assistant SHALL NOT create new meeting artifacts, SHALL NOT collect calendar metadata again, and SHALL NOT run the summary pipeline for the old profile segment.
When switching profiles during an active meeting, Meeting Assistant SHALL stop and drain the current speech recognition pipeline, append a transcript marker indicating the target profile, start a new speech recognition pipeline using the target profile's resolved options, and continue writing live transcription to the same transcript file.
When audio is captured while the old speech recognition pipeline is draining, Meeting Assistant SHALL buffer that audio and send it to the new profile's speech recognition pipeline after it is ready.
When switching profiles during an active meeting, Meeting Assistant SHALL clear run-local speaker label mappings for future transcript segments because backend speaker IDs can change across speech recognition processes. Already-written named transcript segments SHALL remain unchanged.
#### Scenario: Active meeting switches to named profile
- **GIVEN** a meeting was started with the default launch profile
- **WHEN** the user triggers launch profile `english` while recording is active
- **THEN** Meeting Assistant keeps the existing meeting artifacts
- **AND** drains the old speech recognition pipeline without running summary generation
- **AND** appends a transcript marker for the profile switch
- **AND** starts transcription with the resolved `english` profile
- **AND** continues writing transcript segments to the same transcript file
#### Scenario: Captured audio is buffered while switching
- **GIVEN** a meeting is switching from one launch profile to another
- **WHEN** audio chunks are captured before the new speech recognition pipeline is ready
- **THEN** Meeting Assistant buffers those chunks
- **AND** sends the buffered chunks to the new speech recognition pipeline after it starts
#### Scenario: Switching clears future speaker mappings only
- **GIVEN** a live speaker label was mapped to a named speaker before switching profiles
- **WHEN** Meeting Assistant switches to another launch profile
- **THEN** already-written transcript text keeps the named speaker
- **AND** later transcript segments from the new pipeline do not reuse the old backend speaker label mapping
+59 -2
View File
@@ -2,7 +2,6 @@
## Purpose ## Purpose
TBD - created by archiving change define-meeting-assistant-v1. Update Purpose after archive. TBD - created by archiving change define-meeting-assistant-v1. Update Purpose after archive.
## Requirements ## Requirements
### Requirement: Meeting Assistant generates meeting outputs ### Requirement: Meeting Assistant generates meeting outputs
Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context. Meeting Assistant SHALL generate a summary, decisions, and next steps from the meeting transcript, metadata, user notes, and assistant-discovered context.
@@ -33,6 +32,10 @@ The summary pipeline SHALL expose tools scoped to the current meeting:
- `read_usernotes` - `read_usernotes`
- `read_glossary` - `read_glossary`
- `add_dictation_word` - `add_dictation_word`
- `add_attendee`
- `remove_attendee`
- `override_speaker`
- `delete_identity`
- `write_summary` - `write_summary`
- `write_context` - `write_context`
- `list_projects` - `list_projects`
@@ -67,6 +70,18 @@ When the summary note already has an `attendees` frontmatter property, Meeting A
The summary agent SHALL be able to read and write the assistant context body as its own notebook using overwrite, replace, and insert line modes. The summary agent SHALL be able to read and write the assistant context body as its own notebook using overwrite, replace, and insert line modes.
The summary instructions SHALL tell the summary agent to use transcript evidence and screenshot OCR participant evidence to refine the meeting note attendee list conservatively, adding or removing attendees only when the evidence is clear.
The summary instructions SHALL tell the summary agent to call `override_speaker` only when it is very certain that a transcript speaker label belongs to a named speaker, such as from user notes, OCR with a correlating meeting timestamp, or very clear context cues.
When `override_speaker` is called with an existing transcript speaker label and a replacement speaker name, Meeting Assistant SHALL rewrite that speaker label in the transcript and record the override for final speaker identity processing.
When `override_speaker` is called without merge enabled and the transcript already contains the replacement speaker name as a speaker label, Meeting Assistant SHALL refuse the override. When merge is enabled, Meeting Assistant SHALL allow the override, rewrite the source speaker label to the replacement speaker name, and record the override as a speaker merge for final speaker identity processing.
The summary instructions SHALL tell the summary agent to call `delete_identity` only when it is certain that an existing speaker identity was wrongfully matched.
When `delete_identity` is called with an existing transcript speaker identity, Meeting Assistant SHALL rewrite transcript speaker labels for that identity to `Removed-<n>` and record the deletion for final speaker identity processing.
#### Scenario: Assistant context note is initialized #### Scenario: Assistant context note is initialized
- **WHEN** Meeting Assistant starts a meeting session - **WHEN** Meeting Assistant starts a meeting session
- **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note - **THEN** it creates the assistant context note with frontmatter links to the meeting note, transcript note, and summary note
@@ -123,6 +138,44 @@ The summary agent SHALL be able to read and write the assistant context body as
- **THEN** Meeting Assistant preserves the existing assistant context frontmatter - **THEN** Meeting Assistant preserves the existing assistant context frontmatter
- **AND** writes only the replacement body content after that frontmatter - **AND** writes only the replacement body content after that frontmatter
#### Scenario: Summary agent refines attendees from clear participant evidence
- **GIVEN** assistant context OCR states that visible meeting participants are complete
- **AND** the OCR result names a participant missing from the meeting note
- **WHEN** the summary pipeline runs
- **THEN** the summary agent can add that attendee to the meeting note through `add_attendee`
#### Scenario: Summary agent does not over-trim partial participant evidence
- **GIVEN** assistant context OCR states that visible meeting participants are only a partial result
- **WHEN** the summary pipeline runs
- **THEN** the summary instructions tell the agent not to remove attendees solely because they are absent from that partial screenshot evidence
#### Scenario: Summary agent overrides a certain speaker label
- **GIVEN** the transcript contains speaker label `Guest-01`
- **AND** user notes or OCR evidence clearly identifies that speaker as `Sabrina`
- **WHEN** the summary agent calls `override_speaker` with `Guest-01` and `Sabrina`
- **THEN** Meeting Assistant rewrites the transcript speaker label to `Sabrina`
- **AND** records the override for final speaker identity processing
#### Scenario: Summary agent cannot accidentally merge speakers
- **GIVEN** the transcript contains speaker labels `Guest-01` and `Sabrina`
- **WHEN** the summary agent calls `override_speaker` with `Guest-01` and `Sabrina` without merge enabled
- **THEN** Meeting Assistant refuses the override
- **AND** keeps the transcript unchanged
#### Scenario: Summary agent explicitly merges speakers
- **GIVEN** the transcript contains speaker labels `Guest-01` and `Sabrina`
- **AND** user notes or OCR evidence clearly proves they are the same person
- **WHEN** the summary agent calls `override_speaker` with `Guest-01`, `Sabrina`, and merge enabled
- **THEN** Meeting Assistant rewrites `Guest-01` transcript speaker labels to `Sabrina`
- **AND** records the override as a merge for final speaker identity processing
#### Scenario: Summary agent deletes a wrong speaker identity
- **GIVEN** the transcript contains speaker identity `Sabrina`
- **AND** user notes or OCR evidence clearly proves that `Sabrina` was wrongfully matched
- **WHEN** the summary agent calls `delete_identity` with `Sabrina`
- **THEN** Meeting Assistant rewrites the transcript speaker label to `Removed-1`
- **AND** records the deletion for final speaker identity processing
### Requirement: Meeting screenshots are captured into assistant context ### Requirement: Meeting screenshots are captured into assistant context
Meeting Assistant SHALL expose a configurable screenshot hotkey. Meeting Assistant SHALL expose a configurable screenshot hotkey.
@@ -144,7 +197,7 @@ When OCR returns no crop coordinates or invalid crop coordinates, Meeting Assist
When screenshot OCR is not configured, Meeting Assistant SHALL skip OCR and keep the screenshot link. When screenshot OCR is not configured, Meeting Assistant SHALL skip OCR and keep the screenshot link.
The default OCR prompt SHALL explain that the image is from a meeting and ask the model to identify who is talking, who is presenting, what is presented, capture slide text in markdown, convert diagrams to Mermaid when possible, return crop coordinates only for confidently isolated presentation/shared-screen content, and otherwise describe the scene. The default OCR prompt SHALL explain that the image is from a meeting and ask the model to identify who is talking, who is presenting, what is presented, capture slide text in markdown, convert diagrams to Mermaid when possible, indicate whether visible people are clearly the exact meeting participants or only a partial result, return crop coordinates only for confidently isolated presentation/shared-screen content, and otherwise describe the scene.
#### Scenario: Screenshot is linked with meeting timestamp #### Scenario: Screenshot is linked with meeting timestamp
- **GIVEN** a meeting started at `10:00:00` - **GIVEN** a meeting started at `10:00:00`
@@ -170,6 +223,10 @@ The default OCR prompt SHALL explain that the image is from a meeting and ask th
- **WHEN** the user captures a screenshot - **WHEN** the user captures a screenshot
- **THEN** Meeting Assistant saves and links the screenshot without calling a model endpoint - **THEN** Meeting Assistant saves and links the screenshot without calling a model endpoint
#### Scenario: OCR reports whether visible people are complete or partial
- **WHEN** Meeting Assistant uses the built-in screenshot OCR prompt
- **THEN** the prompt asks the model to state whether the screenshot clearly shows exactly who is in the meeting or only a partial participant result
### Requirement: Summary agent instructions are configurable ### Requirement: Summary agent instructions are configurable
Meeting Assistant SHALL allow the summary agent initial prompt to be configured through application settings. Meeting Assistant SHALL allow the summary agent initial prompt to be configured through application settings.
+45 -1
View File
@@ -201,6 +201,14 @@ Each speaker identity SHALL store a last-modified timestamp used by active-age f
The configured maximum snippet count per identity SHALL prevent unbounded growth. The configured maximum snippet count per identity SHALL prevent unbounded growth.
Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished and after automatic summary generation has completed, using the latest meeting note frontmatter.
When the summary agent records a speaker override from a diarized transcript label to a named speaker, final speaker identity processing SHALL attach the current run speaker sample to an existing identity with that name when one exists, or create a new canonical speaker identity with that name when none exists.
When a speaker override maps a current-run unnamed candidate to an existing named identity, Meeting Assistant SHALL merge the candidate's meeting reference and useful snippets into the named identity instead of leaving a duplicate candidate.
When the summary agent records that a speaker identity was wrongfully matched, final speaker identity processing SHALL delete the matching identity from the local speaker identity database so it cannot be matched again unless it is newly created in the future.
#### Scenario: Unknown speaker is learned from meeting attendees #### Scenario: Unknown speaker is learned from meeting attendees
- **WHEN** a finished transcript contains an unmatched diarized speaker and the meeting note has attendees - **WHEN** a finished transcript contains an unmatched diarized speaker and the meeting note has attendees
- **THEN** Meeting Assistant stores a new unnamed speaker identity with candidate names from the attendees that were not already matched in that meeting - **THEN** Meeting Assistant stores a new unnamed speaker identity with candidate names from the attendees that were not already matched in that meeting
@@ -214,6 +222,32 @@ The configured maximum snippet count per identity SHALL prevent unbounded growth
- **WHEN** Meeting Assistant creates, identifies, updates candidates for, stores snippets for, stores references for, or merges a speaker identity - **WHEN** Meeting Assistant creates, identifies, updates candidates for, stores snippets for, stores references for, or merges a speaker identity
- **THEN** Meeting Assistant updates that identity's last-modified timestamp - **THEN** Meeting Assistant updates that identity's last-modified timestamp
#### Scenario: Final speaker identity learning uses summary-refined attendees
- **GIVEN** the summary agent changes meeting note attendees during automatic summary generation
- **WHEN** Meeting Assistant performs final speaker identity learning and candidate creation
- **THEN** it uses the attendee list from the meeting note after the summary agent changes
#### Scenario: Speaker override attaches to existing identity
- **GIVEN** the speaker identity database contains canonical speaker `Sabrina`
- **AND** the summary agent records that transcript speaker `Guest-01` is `Sabrina`
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant stores the meeting reference and current speaker sample on Sabrina's identity
- **AND** does not create a separate unnamed candidate for `Guest-01`
#### Scenario: Speaker override creates named identity
- **GIVEN** the speaker identity database has no accepted name `Sabrina`
- **AND** the summary agent records that transcript speaker `Guest-01` is `Sabrina`
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant creates a canonical speaker identity named `Sabrina`
- **AND** stores the meeting reference and current speaker sample on that identity
#### Scenario: Speaker identity deletion removes a wrong match
- **GIVEN** the speaker identity database contains canonical speaker `Sabrina`
- **AND** the summary agent records that `Sabrina` was wrongfully matched
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant removes Sabrina's identity from the speaker identity database
- **AND** the relabeled transcript uses `Removed-1` instead of `Sabrina`
### Requirement: Speaker identities can be merged diagnostically ### Requirement: Speaker identities can be merged diagnostically
Meeting Assistant SHALL expose a diagnostic endpoint that merges duplicate speaker identities. Meeting Assistant SHALL expose a diagnostic endpoint that merges duplicate speaker identities.
@@ -317,7 +351,7 @@ Meeting Assistant SHALL run live matching incrementally at the configured interv
When a speaker is matched during transcription, Meeting Assistant SHALL rewrite already-written live transcript segments for that diarized speaker and write future transcript segments using the canonical name. When a speaker is matched during transcription, Meeting Assistant SHALL rewrite already-written live transcript segments for that diarized speaker and write future transcript segments using the canonical name.
Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished, using the latest meeting note frontmatter. Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished and after automatic summary generation has completed, using the latest meeting note frontmatter.
For backends that only provide diarization after finalization, Meeting Assistant SHALL defer speaker identity matching until finished diarization is available, extract candidate snippets from the completed temporary recording, complete identity matching, and only then allow summary generation to start. For backends that only provide diarization after finalization, Meeting Assistant SHALL defer speaker identity matching until finished diarization is available, extract candidate snippets from the completed temporary recording, complete identity matching, and only then allow summary generation to start.
@@ -391,3 +425,13 @@ When the Azure live streaming backend uses `ConversationTranscriber`, Meeting As
- **WHEN** `AzureSpeech.PostProcessingOption` is empty - **WHEN** `AzureSpeech.PostProcessingOption` is empty
- **THEN** Meeting Assistant does not set a post-processing option on the Azure Speech SDK configuration - **THEN** Meeting Assistant does not set a post-processing option on the Azure Speech SDK configuration
### Requirement: Speech recognition can be restarted within one meeting
Meeting Assistant SHALL support replacing the active speech recognition pipeline for a recording run while preserving the run's meeting artifacts and transcript session.
The replacement speech recognition pipeline SHALL use the target launch profile's resolved transcription options.
#### Scenario: Replacement pipeline uses target profile options
- **GIVEN** a recording run started with one launch profile
- **WHEN** the active transcription profile is switched to another launch profile
- **THEN** the new speech recognition pipeline is created from the target profile options
- **AND** it receives audio captured after the switch request and audio buffered during the switch