Public Access
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce3c7a0c49 | ||
|
|
693f52afee | ||
|
|
71f1e6a0b8 | ||
|
|
7777b349a5 |
@@ -67,7 +67,7 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
|
||||
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
|
||||
@@ -86,7 +86,7 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "default" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+D" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+Z" &&
|
||||
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "english" &&
|
||||
@@ -100,7 +100,7 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+Z",
|
||||
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
@@ -111,6 +111,23 @@ public sealed class LaunchProfileOptionsProviderTests
|
||||
Assert.DoesNotContain(hotkeys, hotkey => hotkey.ProfileName == "english");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProfilesReturnsDefaultAndConfiguredNamedProfiles()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
|
||||
var profiles = provider.GetProfiles();
|
||||
|
||||
Assert.Equal(["default", "english"], profiles.Select(profile => profile.Name));
|
||||
Assert.Equal("azure-speech", profiles.Single(profile => profile.Name == "english").Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("en-US", profiles.Single(profile => profile.Name == "english").Options.AzureSpeech.Language);
|
||||
}
|
||||
|
||||
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
|
||||
@@ -151,6 +151,36 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
Assert.Equal(1, handler.RequestCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||
|
||||
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
||||
await client.GetResponseAsync([new ChatMessage(ChatRole.Tool, "tool result")]);
|
||||
await client.GetResponseAsync([new ChatMessage(ChatRole.Assistant, "continue")]);
|
||||
|
||||
Assert.Equal(["user", "agent", "agent"], handler.RequestHeaders["X-Initiator"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientUsesResponsesCompactionEndpointWhenContextThresholdIsReached()
|
||||
{
|
||||
@@ -209,6 +239,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(["/v1/responses/compact", "/v1/responses"], handler.RequestPaths);
|
||||
Assert.Contains("\"content\":\"compacted\"", handler.RequestBodies[1]);
|
||||
Assert.Equal(["agent", "user"], handler.RequestHeaders["X-Initiator"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -321,6 +352,8 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
|
||||
public List<string> RequestBodies { get; } = [];
|
||||
|
||||
public Dictionary<string, List<string>> RequestHeaders { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -329,6 +362,17 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
RequestBodies.Add(request.Content is null
|
||||
? string.Empty
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken));
|
||||
foreach (var header in request.Headers)
|
||||
{
|
||||
if (!RequestHeaders.TryGetValue(header.Key, out var values))
|
||||
{
|
||||
values = [];
|
||||
RequestHeaders[header.Key] = values;
|
||||
}
|
||||
|
||||
values.AddRange(header.Value);
|
||||
}
|
||||
|
||||
return handler(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class MeetingSummaryArtifactResolverTests
|
||||
Directory.CreateDirectory(notes);
|
||||
Directory.CreateDirectory(summaries);
|
||||
var notePath = Path.Combine(notes, "meeting.md");
|
||||
var assistantContextPath = Path.Combine(root, "Meetings", "Assistant Context", "context.md");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
|
||||
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
|
||||
await File.WriteAllTextAsync(
|
||||
notePath,
|
||||
"""
|
||||
@@ -47,10 +50,98 @@ public sealed class MeetingSummaryArtifactResolverTests
|
||||
Assert.NotNull(artifacts);
|
||||
Assert.Equal(notePath, artifacts.MeetingNotePath);
|
||||
Assert.Equal(Path.Combine(root, "Meetings", "Transcripts", "transcript.md"), artifacts.TranscriptPath);
|
||||
Assert.Equal(Path.Combine(root, "Meetings", "Assistant Context", "context.md"), artifacts.AssistantContextPath);
|
||||
Assert.Equal(assistantContextPath, artifacts.AssistantContextPath);
|
||||
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverPrefersArtifactLinksFromRequestedSummaryFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var notes = Path.Combine(root, "Meetings", "Notes");
|
||||
var summaries = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(notes);
|
||||
Directory.CreateDirectory(summaries);
|
||||
var notePath = Path.Combine(notes, "meeting.md");
|
||||
var transcriptPath = Path.Combine(root, "Meetings", "Transcripts", "transcript.md");
|
||||
var summaryContextPath = Path.Combine(root, "Meetings", "Assistant Context", "summary-context.md");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(summaryContextPath)!);
|
||||
await File.WriteAllTextAsync(summaryContextPath, "# Summary-linked Assistant Context");
|
||||
await File.WriteAllTextAsync(
|
||||
notePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/stale-context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(summaries, "summary.md"),
|
||||
"""
|
||||
---
|
||||
meeting: "[[../Notes/meeting|Meeting Note]]"
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/summary-context|Assistant Context]]"
|
||||
---
|
||||
""");
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = notes,
|
||||
SummariesFolder = summaries
|
||||
}
|
||||
});
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
options,
|
||||
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(artifacts);
|
||||
Assert.Equal(notePath, artifacts.MeetingNotePath);
|
||||
Assert.Equal(transcriptPath, artifacts.TranscriptPath);
|
||||
Assert.Equal(summaryContextPath, artifacts.AssistantContextPath);
|
||||
Assert.Equal(Path.Combine(summaries, "summary.md"), artifacts.SummaryPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverRejectsSummaryWhenLinkedAssistantContextIsMissing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var notes = Path.Combine(root, "Meetings", "Notes");
|
||||
var summaries = Path.Combine(root, "Meetings", "Summaries");
|
||||
Directory.CreateDirectory(notes);
|
||||
Directory.CreateDirectory(summaries);
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(notes, "meeting.md"),
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/missing-context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
""");
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = notes,
|
||||
SummariesFolder = summaries
|
||||
}
|
||||
});
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
options,
|
||||
new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", CancellationToken.None);
|
||||
|
||||
Assert.Null(artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverRejectsSummaryPathOutsideConfiguredSummaryFolder()
|
||||
{
|
||||
@@ -85,6 +176,9 @@ public sealed class MeetingSummaryArtifactResolverTests
|
||||
Directory.CreateDirectory(englishNotes);
|
||||
Directory.CreateDirectory(englishSummaries);
|
||||
var notePath = Path.Combine(englishNotes, "meeting.md");
|
||||
var assistantContextPath = Path.Combine(englishRoot, "Assistant Context", "context.md");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(assistantContextPath)!);
|
||||
await File.WriteAllTextAsync(assistantContextPath, "# Assistant Context");
|
||||
await File.WriteAllTextAsync(
|
||||
notePath,
|
||||
"""
|
||||
|
||||
@@ -24,6 +24,16 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
|
||||
Assert.Contains("include only the most relevant cropped screenshots", instructions);
|
||||
Assert.Contains("Do not include every cropped screenshot", instructions);
|
||||
Assert.Contains("Use add_attendee and remove_attendee", instructions);
|
||||
Assert.Contains("partial screenshot", instructions);
|
||||
Assert.Contains("override_speaker", instructions);
|
||||
Assert.Contains("very certain", instructions);
|
||||
Assert.Contains("merge=true", instructions);
|
||||
Assert.Contains("delete_identity", instructions);
|
||||
Assert.Contains("wrongfully matched", instructions);
|
||||
Assert.Contains("oneliner", instructions);
|
||||
Assert.Contains("very short", instructions);
|
||||
Assert.Contains("conciseness is more important", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingSummaryRetryRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TriggerStartsRetryInBackgroundAndTransitionsAssistantContextState()
|
||||
{
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
"meeting.md",
|
||||
"transcript.md",
|
||||
"context.md",
|
||||
"summary.md");
|
||||
var resolver = new FixedSummaryArtifactResolver(artifacts);
|
||||
var pipeline = new BlockingSummaryPipeline();
|
||||
var artifactStore = new CapturingArtifactStore();
|
||||
var runner = new MeetingSummaryRetryRunner(
|
||||
resolver,
|
||||
pipeline,
|
||||
artifactStore,
|
||||
NullLogger<MeetingSummaryRetryRunner>.Instance);
|
||||
|
||||
var result = await runner.TriggerAsync(
|
||||
"summary.md",
|
||||
new MeetingAssistantOptions(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("summary.md", result.SummaryPath);
|
||||
Assert.False(pipeline.Completed);
|
||||
await pipeline.WaitUntilStartedAsync();
|
||||
Assert.Equal([AssistantContextState.Summarizing], artifactStore.States);
|
||||
|
||||
pipeline.CompleteSuccessfully();
|
||||
await artifactStore.WaitForStateCountAsync(2);
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Finished],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TriggerMarksAssistantContextErrorWhenRetryFails()
|
||||
{
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
"meeting.md",
|
||||
"transcript.md",
|
||||
"context.md",
|
||||
"summary.md");
|
||||
var resolver = new FixedSummaryArtifactResolver(artifacts);
|
||||
var pipeline = new BlockingSummaryPipeline();
|
||||
var artifactStore = new CapturingArtifactStore();
|
||||
var runner = new MeetingSummaryRetryRunner(
|
||||
resolver,
|
||||
pipeline,
|
||||
artifactStore,
|
||||
NullLogger<MeetingSummaryRetryRunner>.Instance);
|
||||
|
||||
await runner.TriggerAsync(
|
||||
"summary.md",
|
||||
new MeetingAssistantOptions(),
|
||||
CancellationToken.None);
|
||||
await pipeline.WaitUntilStartedAsync();
|
||||
|
||||
pipeline.CompleteWithError();
|
||||
await artifactStore.WaitForStateCountAsync(2);
|
||||
|
||||
Assert.Equal(
|
||||
[AssistantContextState.Summarizing, AssistantContextState.Error],
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
private sealed class FixedSummaryArtifactResolver : IMeetingSummaryArtifactResolver
|
||||
{
|
||||
private readonly MeetingSessionArtifacts? artifacts;
|
||||
|
||||
public FixedSummaryArtifactResolver(MeetingSessionArtifacts? artifacts)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
}
|
||||
|
||||
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(artifacts);
|
||||
}
|
||||
|
||||
public Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(artifacts);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly TaskCompletionSource started =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource<MeetingSummaryRunResult> completion =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public bool Completed { get; private set; }
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
started.TrySetResult();
|
||||
var result = await completion.Task.WaitAsync(cancellationToken);
|
||||
Completed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task WaitUntilStartedAsync()
|
||||
{
|
||||
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void CompleteSuccessfully()
|
||||
{
|
||||
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "ok"));
|
||||
}
|
||||
|
||||
public void CompleteWithError()
|
||||
{
|
||||
completion.TrySetResult(new MeetingSummaryRunResult("summary.md", "failed", false, "failed"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
private TaskCompletionSource stateChanged =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<AssistantContextState> States { get; } = [];
|
||||
|
||||
public Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextMeetingAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
States.Add(state);
|
||||
var observed = stateChanged;
|
||||
observed.TrySetResult();
|
||||
stateChanged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task WaitForStateCountAsync(int count)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
if (States.Count >= count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await stateChanged.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Expected {count} state changes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,11 +47,14 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Equal("User note line.", await tools.ReadUserNotes());
|
||||
Assert.Equal("", await tools.ReadGlossary());
|
||||
|
||||
var result = await tools.WriteSummary("# Summary\n\n- Done.");
|
||||
Assert.False(tools.SummaryWasWritten);
|
||||
var result = await tools.WriteSummary("# Summary\n\n- Done.", "Done items");
|
||||
|
||||
Assert.Equal(artifacts.SummaryPath, result);
|
||||
Assert.True(tools.SummaryWasWritten);
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("title: Meeting", summary);
|
||||
Assert.Contains("oneliner: Done items", summary);
|
||||
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("attendees:", summary);
|
||||
@@ -99,7 +102,7 @@ public sealed class MeetingSummaryToolTests
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
await tools.WriteSummary("# Summary", "Preserved attendee summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("attendees:", summary);
|
||||
@@ -142,7 +145,7 @@ public sealed class MeetingSummaryToolTests
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
await tools.WriteSummary("# Summary", "Current project summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("projects:", summary);
|
||||
@@ -151,6 +154,278 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.DoesNotContain("- Stale Project", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsCanAddAndRemoveMeetingAttendees()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
attendees:
|
||||
- Ada
|
||||
- Grace Hopper <grace@example.com>
|
||||
projects:
|
||||
- Meeting Assistant
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
|
||||
User note line.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
Assert.Equal("Added attendee Manuel.", await tools.AddAttendee("Manuel"));
|
||||
Assert.Equal("Attendee Ada already exists.", await tools.AddAttendee("Ada"));
|
||||
Assert.Equal("Removed attendee Grace Hopper.", await tools.RemoveAttendee("Grace Hopper"));
|
||||
|
||||
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
||||
Assert.Contains("- Ada", meetingNote);
|
||||
Assert.Contains("- Manuel", meetingNote);
|
||||
Assert.DoesNotContain("Grace Hopper", meetingNote);
|
||||
Assert.Contains("User note line.", meetingNote);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", meetingNote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsOverrideSpeakerInTranscriptAndRecordOverride()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Guest-01: Hello from Sabrina.
|
||||
[00:00:02] Guest-02: Hello from someone else.
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
Existing context.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
|
||||
|
||||
Assert.Equal("Overrode speaker Guest-01 with Sabrina.", result);
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Sabrina: Hello from Sabrina.", transcript);
|
||||
Assert.Contains("[00:00:02] Guest-02: Hello from someone else.", transcript);
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.Contains("meeting-assistant:speaker-override", context);
|
||||
Assert.Contains("\"from\":\"Guest-01\"", context);
|
||||
Assert.Contains("\"to\":\"Sabrina\"", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsRefuseOverrideWhenAssistantContextIsMissing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Guest-01: Hello from Sabrina.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
|
||||
|
||||
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(artifacts.AssistantContextPath));
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Guest-01: Hello from Sabrina.", transcript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsRefuseOverrideWhenReplacementSpeakerAlreadyExistsWithoutMerge()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Guest-01: I am probably Sabrina.
|
||||
[00:00:02] Sabrina: I am already in the transcript.
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
Existing context.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina");
|
||||
|
||||
Assert.Equal(
|
||||
"Refused: transcript already contains speaker Sabrina. Call override_speaker with merge=true only when you are certain both speakers are the same identity.",
|
||||
result);
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Guest-01: I am probably Sabrina.", transcript);
|
||||
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.DoesNotContain("meeting-assistant:speaker-override", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsMergeOverrideWhenReplacementSpeakerAlreadyExistsWithMerge()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Guest-01: I am Sabrina.
|
||||
[00:00:02] Sabrina: I am already in the transcript.
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
Existing context.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.OverrideSpeaker("Guest-01", "Sabrina", merge: true);
|
||||
|
||||
Assert.Equal("Merged speaker Guest-01 into Sabrina.", result);
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Sabrina: I am Sabrina.", transcript);
|
||||
Assert.Contains("[00:00:02] Sabrina: I am already in the transcript.", transcript);
|
||||
Assert.DoesNotContain("Guest-01:", transcript);
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("meeting-assistant:speaker-override", context);
|
||||
Assert.Contains("\"from\":\"Guest-01\"", context);
|
||||
Assert.Contains("\"to\":\"Sabrina\"", context);
|
||||
Assert.Contains("\"merge\":true", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsDeleteIdentityInTranscriptAndRecordDeletion()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Sabrina: This was incorrectly matched.
|
||||
[00:00:02] Removed-1: An earlier removed speaker.
|
||||
[00:00:03] Guest-02: Hello from someone else.
|
||||
[00:00:04] Sabrina: Another incorrect line.
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
Existing context.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.DeleteIdentity("Sabrina");
|
||||
|
||||
Assert.Equal("Deleted identity Sabrina and relabeled transcript speaker mentions to Removed-2.", result);
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Removed-2: This was incorrectly matched.", transcript);
|
||||
Assert.Contains("[00:00:02] Removed-1: An earlier removed speaker.", transcript);
|
||||
Assert.Contains("[00:00:03] Guest-02: Hello from someone else.", transcript);
|
||||
Assert.Contains("[00:00:04] Removed-2: Another incorrect line.", transcript);
|
||||
Assert.DoesNotContain("Sabrina:", transcript);
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.Contains("meeting-assistant:speaker-identity-deletion", context);
|
||||
Assert.Contains("\"identity\":\"Sabrina\"", context);
|
||||
Assert.Contains("\"replacement\":\"Removed-2\"", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsRefuseDeleteIdentityWhenAssistantContextIsMissing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.TranscriptPath,
|
||||
"""
|
||||
# Meeting Transcript
|
||||
|
||||
[00:00:01] Sabrina: This was incorrectly matched.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.DeleteIdentity("Sabrina");
|
||||
|
||||
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(artifacts.AssistantContextPath));
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath);
|
||||
Assert.Contains("[00:00:01] Sabrina: This was incorrectly matched.", transcript);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadToolsSupportClampedLineRanges()
|
||||
{
|
||||
@@ -236,6 +511,23 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("replacement\ninserted\nline two", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsRefuseWriteContextWhenAssistantContextIsMissing()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.WriteContext("new context");
|
||||
|
||||
Assert.StartsWith("Refused: assistant context file does not exist", result, StringComparison.Ordinal);
|
||||
Assert.False(File.Exists(artifacts.AssistantContextPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody()
|
||||
{
|
||||
@@ -321,7 +613,7 @@ public sealed class MeetingSummaryToolTests
|
||||
audit);
|
||||
|
||||
await tools.WriteContext("owned context");
|
||||
await tools.WriteSummary("owned summary");
|
||||
await tools.WriteSummary("owned summary", "Owned summary");
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO", from: 2, to: 2);
|
||||
await tools.AddDictationWord("PBI");
|
||||
await audit.AppendToAssistantContextAsync(CancellationToken.None);
|
||||
@@ -336,4 +628,30 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.DoesNotContain("owned summary", context);
|
||||
Assert.DoesNotContain("- Context before.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSummaryRefusesOneLinerWithLineBreaks()
|
||||
{
|
||||
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
|
||||
---
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
var result = await tools.WriteSummary("# Summary", "First line\nsecond line");
|
||||
|
||||
Assert.Equal("Refused: oneliner must be a single line.", result);
|
||||
Assert.False(tools.SummaryWasWritten);
|
||||
Assert.False(File.Exists(artifacts.SummaryPath));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +582,139 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileWhileRecordingSwitchesPipelineWithoutNewArtifactsOrSummary()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new ProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
pipelineFactory,
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
var started = await coordinator.ToggleAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:2");
|
||||
|
||||
var switched = await coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("english chunk:4");
|
||||
|
||||
Assert.True(switched.IsRecording);
|
||||
Assert.Equal(started.TranscriptPath, switched.TranscriptPath);
|
||||
Assert.Equal(started.MeetingNotePath, switched.MeetingNotePath);
|
||||
Assert.Equal(started.AssistantContextPath, switched.AssistantContextPath);
|
||||
Assert.Equal(started.SummaryPath, switched.SummaryPath);
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Speaker == "System" &&
|
||||
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileBuffersAudioCapturedWhilePreviousPipelineDrains()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var pipelineFactory = new BlockingProfileSwitchSpeechRecognitionPipelineFactory();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
pipelineFactory,
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0], 16000, 1), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:2");
|
||||
|
||||
var switchTask = coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await pipelineFactory.WaitUntilFirstPipelineDrainIsBlockedAsync();
|
||||
await audioSource.WriteAsync(new AudioChunk([1, 0, 2, 0, 3, 0], 16000, 1), CancellationToken.None);
|
||||
|
||||
Assert.False(switchTask.IsCompleted);
|
||||
|
||||
pipelineFactory.ReleaseFirstPipelineDrain();
|
||||
await switchTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await transcriptStore.WaitForTextAsync("english chunk:6");
|
||||
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToggleToDifferentLaunchProfileClearsFutureSpeakerMappings()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var speakerIdentification = new SingleMappingSpeakerIdentificationService("Guest03", "Chris");
|
||||
var launchProfiles = CreateLaunchProfiles(
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"),
|
||||
Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"),
|
||||
enableSpeakerIdentification: true);
|
||||
var options = launchProfiles.GetRequiredProfile(null).Options;
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new ProfileSwitchSpeechRecognitionPipelineFactory("Guest03"),
|
||||
transcriptStore,
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification,
|
||||
launchProfiles: launchProfiles);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("default chunk:");
|
||||
await speakerIdentification.IdentificationObserved.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
await WaitUntilAsync(() => transcriptStore.ReplacedSegments.Any(segment => segment.Speaker == "Chris"));
|
||||
|
||||
await coordinator.ToggleAsync("english", CancellationToken.None);
|
||||
await audioSource.WriteAsync(CreateThreeSecondChunk(), CancellationToken.None);
|
||||
await transcriptStore.WaitForTextAsync("english chunk:");
|
||||
|
||||
var afterMarker = transcriptStore.Segments
|
||||
.SkipWhile(segment => !segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal))
|
||||
.Skip(1)
|
||||
.ToList();
|
||||
Assert.Contains(afterMarker, segment => segment.Speaker == "Guest03");
|
||||
Assert.DoesNotContain(afterMarker, segment => segment.Speaker == "Chris");
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartUsesLaunchProfileStorageAndSummaryOptions()
|
||||
{
|
||||
@@ -1263,6 +1396,118 @@ public sealed class RecordingCoordinatorTests
|
||||
artifactStore.States);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingUsesAttendeesUpdatedBySummary()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var noteStore = new InMemoryMeetingNoteStore();
|
||||
var speakerIdentification = new CapturingFinalSpeakerIdentificationService();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest03", "hello")]);
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new AttendeeUpdatingSummaryPipeline(noteStore, ["Summary Attendee"]),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Summary Attendee"], speakerIdentification.FinalAttendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingAppliesSummarySpeakerOverrides()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
|
||||
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
|
||||
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService();
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
|
||||
var options = CreateOptionsWithoutFinalizer();
|
||||
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
|
||||
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
|
||||
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
|
||||
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
|
||||
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
|
||||
new SummarySpeakerOverridePipeline("Guest-01", "Sabrina"),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(("Guest-01", "Sabrina"), speakerIdentification.Overrides.Single());
|
||||
Assert.Equal("Sabrina", speakerIdentification.FinalSegments.Single().Speaker);
|
||||
Assert.Equal("Sabrina", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
Assert.Equal("Sabrina", speakerIdentification.FinalKnownSpeakerMappings["Guest-01"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalSpeakerIdentityProcessingAppliesSummaryIdentityDeletions()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var transcriptStore = new InMemoryTranscriptStore(Path.Combine(root, "Transcripts", "transcript.md"));
|
||||
var noteStore = new InMemoryMeetingNoteStore(Path.Combine(root, "Notes", "meeting.md"));
|
||||
var speakerIdentification = new CapturingOverrideSpeakerIdentificationService("Guest-01", "Sabrina");
|
||||
var finalizer = new CapturingTranscriptFinalizer(
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(1), "Guest-01", "hello")]);
|
||||
var options = CreateOptionsWithoutFinalizer();
|
||||
options.Vault.MeetingNotesFolder = Path.Combine(root, "Notes");
|
||||
options.Vault.TranscriptsFolder = Path.Combine(root, "Transcripts");
|
||||
options.Vault.AssistantContextFolder = Path.Combine(root, "Assistant Context");
|
||||
options.Vault.SummariesFolder = Path.Combine(root, "Summaries");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(
|
||||
new FinalSegmentOnAudioCompletionProvider(),
|
||||
finalizer.FinalizeAsync),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(createAssistantContextFile: true),
|
||||
new InMemoryRecordedAudioStore(Path.Combine(root, "recording.wav")),
|
||||
new SummaryIdentityDeletionPipeline("Sabrina"),
|
||||
Options.Create(options),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
speakerIdentificationService: speakerIdentification);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("Sabrina", speakerIdentification.DeletedIdentities.Single());
|
||||
Assert.Equal("Removed-1", speakerIdentification.FinalSegments.Single().Speaker);
|
||||
Assert.Equal("Removed-1", transcriptStore.ReplacedSegments.Single().Speaker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecordingLifecycleRunsMeetingWorkflowEvents()
|
||||
{
|
||||
@@ -1398,25 +1643,39 @@ public sealed class RecordingCoordinatorTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AudioChunk CreateThreeSecondChunk()
|
||||
{
|
||||
return new AudioChunk(new byte[16000 * 2 * 3], 16000, 1);
|
||||
}
|
||||
|
||||
private static ILaunchProfileOptionsProvider CreateLaunchProfiles(
|
||||
string defaultRoot,
|
||||
string englishRoot)
|
||||
string englishRoot,
|
||||
bool enableSpeakerIdentification = false)
|
||||
{
|
||||
var values = new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
|
||||
["MeetingAssistant:Agent:Model"] = "default-summary-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
|
||||
};
|
||||
if (enableSpeakerIdentification)
|
||||
{
|
||||
values["MeetingAssistant:SpeakerIdentification:Enabled"] = "true";
|
||||
values["MeetingAssistant:SpeakerIdentification:InitialDelay"] = "00:00:00";
|
||||
values["MeetingAssistant:SpeakerIdentification:Interval"] = "00:00:00.050";
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Vault:BaseFolder"] = defaultRoot,
|
||||
["MeetingAssistant:Vault:TranscriptsFolder"] = "Transcripts",
|
||||
["MeetingAssistant:Vault:MeetingNotesFolder"] = "Notes",
|
||||
["MeetingAssistant:Vault:AssistantContextFolder"] = "Context",
|
||||
["MeetingAssistant:Vault:SummariesFolder"] = "Summaries",
|
||||
["MeetingAssistant:Recording:TemporaryRecordingsFolder"] = Path.Combine(defaultRoot, "Recordings"),
|
||||
["MeetingAssistant:Agent:Model"] = "default-summary-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Vault:BaseFolder"] = englishRoot,
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TemporaryRecordingsFolder"] = Path.Combine(englishRoot, "Recordings"),
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-summary-model"
|
||||
})
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new ConfigurationLaunchProfileOptionsProvider(configuration);
|
||||
}
|
||||
@@ -1737,6 +1996,13 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
private readonly bool createAssistantContextFile;
|
||||
|
||||
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
|
||||
{
|
||||
this.createAssistantContextFile = createAssistantContextFile;
|
||||
}
|
||||
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
|
||||
public List<AssistantContextState> States { get; } = [];
|
||||
@@ -1758,6 +2024,21 @@ public sealed class RecordingCoordinatorTests
|
||||
ContextMeetingNote = meetingNote;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
if (createAssistantContextFile)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
return File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
# Assistant Context
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1855,6 +2136,19 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult<MeetingMetadata?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
@@ -1953,6 +2247,102 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AttendeeUpdatingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly InMemoryMeetingNoteStore noteStore;
|
||||
private readonly IReadOnlyList<string> attendees;
|
||||
|
||||
public AttendeeUpdatingSummaryPipeline(
|
||||
InMemoryMeetingNoteStore noteStore,
|
||||
IReadOnlyList<string> attendees)
|
||||
{
|
||||
this.noteStore = noteStore;
|
||||
this.attendees = attendees;
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
noteStore.UpdateAttendees(attendees);
|
||||
return Task.FromResult(new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok"));
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SummarySpeakerOverridePipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
|
||||
public SummarySpeakerOverridePipeline(
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
new SpeakerOverride(sourceSpeaker, targetSpeaker),
|
||||
cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SummaryIdentityDeletionPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly string identity;
|
||||
|
||||
public SummaryIdentityDeletionPipeline(string identity)
|
||||
{
|
||||
this.identity = identity;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
|
||||
artifacts.TranscriptPath,
|
||||
identity,
|
||||
cancellationToken);
|
||||
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
deletion,
|
||||
cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok");
|
||||
}
|
||||
|
||||
public Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingFirstFinalizer
|
||||
{
|
||||
private readonly TaskCompletionSource firstFinalizerBlocked =
|
||||
@@ -2224,6 +2614,129 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly string speaker;
|
||||
|
||||
public ProfileSwitchSpeechRecognitionPipelineFactory(string speaker = "Unknown")
|
||||
{
|
||||
this.speaker = speaker;
|
||||
}
|
||||
|
||||
public List<string?> ProfileNames { get; } = [];
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
ProfileNames.Add(launchProfileName);
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", speaker),
|
||||
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingProfileSwitchSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly TaskCompletionSource firstPipelineDrainBlocked =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseFirstPipelineDrain =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private int created;
|
||||
|
||||
public List<string?> ProfileNames { get; } = [];
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
ProfileNames.Add(launchProfileName);
|
||||
var createIndex = Interlocked.Increment(ref created);
|
||||
var provider = createIndex == 1
|
||||
? new BlockingOnCompletionProfileSwitchTranscriptionProvider(
|
||||
launchProfileName ?? "default",
|
||||
firstPipelineDrainBlocked,
|
||||
releaseFirstPipelineDrain)
|
||||
: new ProfileSwitchTranscriptionProvider(launchProfileName ?? "default", "Unknown");
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
provider,
|
||||
(_, _, _, _) => Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]));
|
||||
}
|
||||
|
||||
public Task WaitUntilFirstPipelineDrainIsBlockedAsync()
|
||||
{
|
||||
return firstPipelineDrainBlocked.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void ReleaseFirstPipelineDrain()
|
||||
{
|
||||
releaseFirstPipelineDrain.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private class ProfileSwitchTranscriptionProvider : IStreamingTranscriptionProvider
|
||||
{
|
||||
private readonly string profileName;
|
||||
private readonly string speaker;
|
||||
|
||||
public ProfileSwitchTranscriptionProvider(string profileName, string speaker)
|
||||
{
|
||||
this.profileName = profileName;
|
||||
this.speaker = speaker;
|
||||
}
|
||||
|
||||
public virtual async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in audio.WithCancellation(cancellationToken))
|
||||
{
|
||||
yield return new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(3),
|
||||
speaker,
|
||||
$"{profileName} chunk:{chunk.Pcm.Length} has enough words for identification.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingOnCompletionProfileSwitchTranscriptionProvider : ProfileSwitchTranscriptionProvider
|
||||
{
|
||||
private readonly TaskCompletionSource drainBlocked;
|
||||
private readonly TaskCompletionSource releaseDrain;
|
||||
|
||||
public BlockingOnCompletionProfileSwitchTranscriptionProvider(
|
||||
string profileName,
|
||||
TaskCompletionSource drainBlocked,
|
||||
TaskCompletionSource releaseDrain)
|
||||
: base(profileName, "Unknown")
|
||||
{
|
||||
this.drainBlocked = drainBlocked;
|
||||
this.releaseDrain = releaseDrain;
|
||||
}
|
||||
|
||||
public override async IAsyncEnumerable<TranscriptionSegment> TranscribeAsync(
|
||||
IAsyncEnumerable<AudioChunk> audio,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in base.TranscribeAsync(audio, options, cancellationToken))
|
||||
{
|
||||
yield return segment;
|
||||
}
|
||||
|
||||
drainBlocked.TrySetResult();
|
||||
await releaseDrain.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
@@ -2348,6 +2861,143 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleMappingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string sourceSpeaker;
|
||||
private readonly string targetSpeaker;
|
||||
private int mapped;
|
||||
|
||||
public SingleMappingSpeakerIdentificationService(string sourceSpeaker, string targetSpeaker)
|
||||
{
|
||||
this.sourceSpeaker = sourceSpeaker;
|
||||
this.targetSpeaker = targetSpeaker;
|
||||
}
|
||||
|
||||
public TaskCompletionSource IdentificationObserved { get; } =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IdentificationObserved.TrySetResult();
|
||||
if (Interlocked.Exchange(ref mapped, 1) == 0)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments,
|
||||
new Dictionary<string, string> { [sourceSpeaker] = targetSpeaker },
|
||||
[new SpeakerIdentityAttendeeMatch(targetSpeaker, [targetSpeaker])]));
|
||||
}
|
||||
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingFinalSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
public IReadOnlyList<string> FinalAttendees { get; private set; } = [];
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FinalAttendees = request.MeetingNote.Frontmatter.Attendees.ToList();
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingOverrideSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
private readonly string? readOnlySourceSpeaker;
|
||||
private readonly string? readOnlyTargetSpeaker;
|
||||
|
||||
public CapturingOverrideSpeakerIdentificationService(
|
||||
string? readOnlySourceSpeaker = null,
|
||||
string? readOnlyTargetSpeaker = null)
|
||||
{
|
||||
this.readOnlySourceSpeaker = readOnlySourceSpeaker;
|
||||
this.readOnlyTargetSpeaker = readOnlyTargetSpeaker;
|
||||
}
|
||||
|
||||
public List<(string SourceSpeaker, string TargetSpeaker)> Overrides { get; } = [];
|
||||
|
||||
public List<string> DeletedIdentities { get; } = [];
|
||||
|
||||
public IReadOnlyList<TranscriptionSegment> FinalSegments { get; private set; } = [];
|
||||
|
||||
public IReadOnlyDictionary<string, string> FinalKnownSpeakerMappings { get; private set; } =
|
||||
new Dictionary<string, string>();
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(readOnlySourceSpeaker) ||
|
||||
string.IsNullOrWhiteSpace(readOnlyTargetSpeaker))
|
||||
{
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
|
||||
return Task.FromResult(new SpeakerIdentificationResult(
|
||||
request.Segments.Select(segment => segment.Speaker == readOnlySourceSpeaker
|
||||
? segment with { Speaker = readOnlyTargetSpeaker }
|
||||
: segment).ToList(),
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[readOnlySourceSpeaker] = readOnlyTargetSpeaker
|
||||
}));
|
||||
}
|
||||
|
||||
public Task ApplySpeakerOverrideAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Overrides.Add((sourceSpeaker, targetSpeaker));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteSpeakerIdentityAsync(
|
||||
string identity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeletedIdentities.Add(identity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FinalSegments = request.Segments;
|
||||
FinalKnownSpeakerMappings = request.KnownSpeakerMappings ??
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult(new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>()));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CountingSpeakerIdentificationService : ISpeakerIdentificationService
|
||||
{
|
||||
public List<SpeakerIdentificationRequest> Requests { get; } = [];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using MeetingAssistant;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class ScreenshotOcrOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultPromptAsksForCompleteOrPartialVisibleParticipantEvidence()
|
||||
{
|
||||
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
|
||||
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,74 @@ public sealed class SpeakerIdentityServiceTests
|
||||
Assert.Equal(0, fixture.SnippetExtractor.ExtractCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerOverrideAttachesCurrentSampleToExistingNamedIdentity()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1);
|
||||
var service = fixture.CreateService();
|
||||
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
|
||||
var request = fixture.CreateRequest(
|
||||
["Sabrina"],
|
||||
[segment],
|
||||
[new SpeakerAudioSample("Guest-01", segment, [9, 9, 9], 95)]);
|
||||
|
||||
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Sabrina"],
|
||||
[segment with { Speaker = "Sabrina" }],
|
||||
knownSpeakerMappings: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Guest-01"] = "Sabrina"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(sabrina.Id);
|
||||
Assert.Equal("Sabrina", saved.CanonicalName);
|
||||
Assert.Equal(2, saved.References.Count);
|
||||
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 9, 9, 9 }));
|
||||
var allIdentities = await fixture.Context.SpeakerIdentities.ToListAsync();
|
||||
Assert.Single(allIdentities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerOverrideCreatesNamedIdentityWhenNoAcceptedNameExists()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var service = fixture.CreateService();
|
||||
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
|
||||
var request = fixture.CreateRequest(
|
||||
["Sabrina"],
|
||||
[segment],
|
||||
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
|
||||
|
||||
await service.ApplySpeakerOverrideAsync(request, "Guest-01", "Sabrina", CancellationToken.None);
|
||||
|
||||
var saved = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.References)
|
||||
.Include(identity => identity.Snippets)
|
||||
.SingleAsync();
|
||||
Assert.Equal("Sabrina", saved.CanonicalName);
|
||||
Assert.Single(saved.References);
|
||||
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerIdentityDeletionRemovesAcceptedNameFromDatabase()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var sabrina = await fixture.AddIdentityAsync([], [1, 2, 3], "Sabrina", referenceCount: 1, aliases: ["Sabi"]);
|
||||
await fixture.AddIdentityAsync([], [4, 5, 6], "Manuel", referenceCount: 1);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.DeleteSpeakerIdentityAsync("Sabi", CancellationToken.None);
|
||||
|
||||
var identities = await fixture.Context.SpeakerIdentities.ToListAsync();
|
||||
Assert.DoesNotContain(identities, identity => identity.Id == sabrina.Id);
|
||||
Assert.Contains(identities, identity => identity.CanonicalName == "Manuel");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Taskbar;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class TaskbarIconTests
|
||||
{
|
||||
[Fact]
|
||||
public void IdleMenuOffersStartForEachConfiguredProfile()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(),
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Idle, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.EditRules &&
|
||||
item.Text == "Edit rules and identities");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default" &&
|
||||
item.Text == "Start meeting recording (default)\tCtrl+Alt+M");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "english" &&
|
||||
item.Text == "Start meeting recording (english)\tCtrl+Alt+E");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+E"), Profile("french", "Ctrl+Alt+F")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "english" &&
|
||||
item.Text == "Switch to english\tCtrl+Alt+E");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "french" &&
|
||||
item.Text == "Switch to french\tCtrl+Alt+F");
|
||||
Assert.DoesNotContain(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "default");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessingMenuShowsSummarizingButAllowsStartingNewRecordings()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(state: RecordingProcessState.Summarizing, profile: "default"),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Summarizing, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "english");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingStateWinsOverOlderSummarizingWork()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "english"),
|
||||
[Profile("default"), Profile("english")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
}
|
||||
|
||||
private static LaunchProfile Profile(string name, string hotkey = "")
|
||||
{
|
||||
return new LaunchProfile(name, new MeetingAssistantOptions
|
||||
{
|
||||
Hotkey =
|
||||
{
|
||||
Toggle = hotkey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static RecordingStatus Status(
|
||||
bool isRecording = false,
|
||||
RecordingProcessState state = RecordingProcessState.Idle,
|
||||
string? profile = null)
|
||||
{
|
||||
return new RecordingStatus(
|
||||
isRecording,
|
||||
state == RecordingProcessState.Idle ? null : "transcript.md",
|
||||
state == RecordingProcessState.Idle ? null : "meeting.md",
|
||||
state == RecordingProcessState.Idle ? null : "context.md",
|
||||
state == RecordingProcessState.Idle ? null : "summary.md",
|
||||
state,
|
||||
profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
using MeetingAssistant.Workflow;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class WorkflowRulesEditorTests
|
||||
{
|
||||
[Fact]
|
||||
public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues()
|
||||
{
|
||||
var defaults = new AgentOptions
|
||||
{
|
||||
Endpoint = "https://summary.example",
|
||||
Key = "summary-key",
|
||||
KeyEnv = "SUMMARY_KEY",
|
||||
Model = "summary-model",
|
||||
EnableThinking = true,
|
||||
ReasoningEffort = ReasoningEffortOption.High,
|
||||
ReconnectionAttempts = 7,
|
||||
ReconnectionDelay = TimeSpan.FromSeconds(7),
|
||||
ContextWindowTokens = 1000,
|
||||
MaxOutputTokens = 100,
|
||||
EnableCompaction = true,
|
||||
CompactionRemainingRatio = 0.2,
|
||||
ResponsesCompactPath = "responses/compact"
|
||||
};
|
||||
var editor = new WorkflowRulesEditorOptions
|
||||
{
|
||||
Model = "editor-model",
|
||||
EnableThinking = false,
|
||||
MaxOutputTokens = 50
|
||||
};
|
||||
|
||||
var effective = editor.ToEffectiveAgentOptions(defaults);
|
||||
|
||||
Assert.Equal("https://summary.example", effective.Endpoint);
|
||||
Assert.Equal("summary-key", effective.Key);
|
||||
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
|
||||
Assert.Equal("editor-model", effective.Model);
|
||||
Assert.False(effective.EnableThinking);
|
||||
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
|
||||
Assert.Equal(7, effective.ReconnectionAttempts);
|
||||
Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay);
|
||||
Assert.Equal(1000, effective.ContextWindowTokens);
|
||||
Assert.Equal(50, effective.MaxOutputTokens);
|
||||
Assert.True(effective.EnableCompaction);
|
||||
Assert.Equal(0.2, effective.CompactionRemainingRatio);
|
||||
Assert.Equal("responses/compact", effective.ResponsesCompactPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
var unrelatedPath = Path.Combine(root, "other.yaml");
|
||||
await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
});
|
||||
|
||||
var writeResult = await tools.WriteRules("""
|
||||
rules:
|
||||
- name: add-me
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
""");
|
||||
var readResult = await tools.ReadRules();
|
||||
var searchResult = await tools.Search("add-me");
|
||||
|
||||
Assert.Equal(rulesPath, writeResult);
|
||||
Assert.Contains("name: add-me", readResult);
|
||||
Assert.Contains("rules.yaml:", searchResult);
|
||||
Assert.DoesNotContain("other.yaml", searchResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
await File.WriteAllTextAsync(rulesPath, "rules: []");
|
||||
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
});
|
||||
|
||||
var result = await tools.WriteRules("rules:\n- name: [");
|
||||
|
||||
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
|
||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
var builder = new WorkflowRulesEditorInstructionBuilder(
|
||||
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
|
||||
|
||||
var instructions = await builder.BuildAsync(new MeetingAssistantOptions
|
||||
{
|
||||
Automation = new AutomationOptions { RulesPath = rulesPath }
|
||||
}, CancellationToken.None);
|
||||
|
||||
Assert.Contains(rulesPath, instructions);
|
||||
Assert.Contains("Meeting Workflow Engine", instructions);
|
||||
Assert.Contains("Workflow rules reference documentation", instructions);
|
||||
Assert.Contains("speaker identity database", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var tools = fixture.CreateTools();
|
||||
|
||||
var createResult = await tools.CreateIdentity(
|
||||
"Sabrina",
|
||||
["Sabi"],
|
||||
["Guest-01"]);
|
||||
using var created = JsonDocument.Parse(createResult);
|
||||
var identityId = created.RootElement
|
||||
.GetProperty("identity")
|
||||
.GetProperty("id")
|
||||
.GetInt32();
|
||||
|
||||
var searchResult = await tools.SearchIdentities("sabi");
|
||||
var readResult = await tools.ReadIdentity(identityId);
|
||||
var updateResult = await tools.UpdateIdentity(
|
||||
identityId,
|
||||
"Sabrina M.",
|
||||
["Sabrina"],
|
||||
["Guest-02"]);
|
||||
var deleteResult = await tools.DeleteIdentity(identityId);
|
||||
|
||||
Assert.Contains("\"displayName\": \"Sabrina\"", searchResult);
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult);
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult);
|
||||
Assert.Equal($"Deleted identity {identityId}.", deleteResult);
|
||||
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]);
|
||||
var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]);
|
||||
var tools = fixture.CreateTools();
|
||||
|
||||
var result = await tools.MergeIdentities(target.Id, source.Id);
|
||||
|
||||
Assert.Contains("\"canonicalName\": \"Sabrina\"", result);
|
||||
var saved = await fixture.LoadIdentityAsync(target.Id);
|
||||
Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order());
|
||||
var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray();
|
||||
Assert.Equal([1], snippets[0]);
|
||||
Assert.Equal([2, 3], snippets[1]);
|
||||
Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples()
|
||||
{
|
||||
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
|
||||
var sampleId = identity.Snippets.Single().Id;
|
||||
var queue = new CapturingSamplePlaybackQueue();
|
||||
var tools = fixture.CreateTools(queue);
|
||||
|
||||
var listResult = await tools.ListIdentitySamples(identity.Id);
|
||||
var readResult = await tools.ReadIdentitySample(sampleId);
|
||||
var playResult = await tools.QueuePlayIdentitySample(sampleId);
|
||||
var deleteResult = await tools.DeleteIdentitySample(sampleId);
|
||||
|
||||
Assert.Contains($"\"id\": {sampleId}", listResult);
|
||||
Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult);
|
||||
Assert.Equal($"Queued sample {sampleId}.", playResult);
|
||||
var queued = queue.Queued.Single();
|
||||
Assert.Equal(sampleId, queued.SampleId);
|
||||
Assert.Equal([4, 5, 6], queued.WavBytes);
|
||||
Assert.Equal($"Deleted sample {sampleId}.", deleteResult);
|
||||
Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage()
|
||||
{
|
||||
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "Rename ABS Daily"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(
|
||||
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
|
||||
viewModel.Messages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal(2, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 500, 0, true)]
|
||||
[InlineData(0, 500, 400, true)]
|
||||
[InlineData(492, 500, 1000, true)]
|
||||
[InlineData(300, 500, 1000, false)]
|
||||
public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom(
|
||||
double verticalOffset,
|
||||
double viewportHeight,
|
||||
double contentHeight,
|
||||
bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
verticalOffset,
|
||||
viewportHeight,
|
||||
contentHeight));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows()
|
||||
{
|
||||
Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
verticalOffset: 0,
|
||||
viewportHeight: 500,
|
||||
previousContentHeight: 500));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScrollPolicyComputesNonNegativeBottomOffset()
|
||||
{
|
||||
Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000));
|
||||
Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesRequestedInlineStyles()
|
||||
{
|
||||
var inlines = WorkflowRulesEditorMarkdown.ParseInline(
|
||||
"Normal *italic* **bold** and `code`.");
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic),
|
||||
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold),
|
||||
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
|
||||
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
|
||||
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
|
||||
],
|
||||
inlines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserPreservesParagraphLineBreaks()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
First line
|
||||
Second line with `code`
|
||||
""");
|
||||
|
||||
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(Assert.Single(blocks));
|
||||
Assert.Contains(paragraph.Inlines, inline =>
|
||||
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal &&
|
||||
inline.Text.Contains('\n'));
|
||||
Assert.Contains(paragraph.Inlines, inline =>
|
||||
inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code &&
|
||||
inline.Text == "code");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesPipeTables()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
Current identities:
|
||||
|
||||
| ID | Name | Samples |
|
||||
|----|------|---------|
|
||||
| 1 | Manuel | 2 |
|
||||
| 2 | Sabrina | 1 |
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
||||
block =>
|
||||
{
|
||||
var table = Assert.IsType<WorkflowRulesEditorMarkdownTable>(block);
|
||||
Assert.Equal(["ID", "Name", "Samples"], table.Header);
|
||||
Assert.Equal(["1", "Manuel", "2"], table.Rows[0]);
|
||||
Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkdownParserRecognizesFencedCodeBlocks()
|
||||
{
|
||||
var blocks = WorkflowRulesEditorMarkdown.Parse("""
|
||||
Before
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: example
|
||||
```
|
||||
|
||||
After **bold**
|
||||
""");
|
||||
|
||||
Assert.Collection(
|
||||
blocks,
|
||||
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
|
||||
block =>
|
||||
{
|
||||
var code = Assert.IsType<WorkflowRulesEditorMarkdownCodeBlock>(block);
|
||||
Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text);
|
||||
},
|
||||
block =>
|
||||
{
|
||||
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block);
|
||||
Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
|
||||
public BlockingRulesEditorPipeline(string response)
|
||||
{
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Started.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
|
||||
private WorkflowRulesEditorIdentityFixture(
|
||||
string tempDirectory,
|
||||
string dbPath,
|
||||
SpeakerIdentityDbContext context)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext Context { get; }
|
||||
|
||||
public static async Task<WorkflowRulesEditorIdentityFixture> CreateAsync()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
var dbPath = Path.Combine(tempDirectory, "speaker-identities.db");
|
||||
var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context);
|
||||
}
|
||||
|
||||
public WorkflowRulesEditorTools CreateTools(
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
|
||||
{
|
||||
return new WorkflowRulesEditorTools(
|
||||
new MeetingAssistantOptions(),
|
||||
new TestSpeakerIdentityDbContextFactory(dbPath),
|
||||
samplePlaybackQueue);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
string canonicalName,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
IReadOnlyList<string>? candidates = null,
|
||||
byte[]? sample = null)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [],
|
||||
Snippets = sample is null
|
||||
? []
|
||||
:
|
||||
[
|
||||
new SpeakerSnippet
|
||||
{
|
||||
WavBytes = sample,
|
||||
CreatedAt = now
|
||||
}
|
||||
]
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
return identity;
|
||||
}
|
||||
|
||||
public Task<SpeakerIdentity> LoadIdentityAsync(int id)
|
||||
{
|
||||
Context.ChangeTracker.Clear();
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Snippets)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory<SpeakerIdentityDbContext>
|
||||
{
|
||||
private readonly string dbPath;
|
||||
|
||||
public TestSpeakerIdentityDbContextFactory(string dbPath)
|
||||
{
|
||||
this.dbPath = dbPath;
|
||||
}
|
||||
|
||||
public SpeakerIdentityDbContext CreateDbContext()
|
||||
{
|
||||
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue
|
||||
{
|
||||
public List<(int SampleId, byte[] WavBytes)> Queued { get; } = [];
|
||||
|
||||
public Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Queued.Add((sampleId, wavBytes));
|
||||
return Task.FromResult($"Queued sample {sampleId}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -6,6 +6,8 @@ public interface ILaunchProfileOptionsProvider
|
||||
{
|
||||
LaunchProfile GetRequiredProfile(string? name);
|
||||
|
||||
IReadOnlyList<LaunchProfile> GetProfiles();
|
||||
|
||||
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
|
||||
}
|
||||
|
||||
@@ -77,6 +79,13 @@ public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOp
|
||||
return hotkeys;
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfile> GetProfiles()
|
||||
{
|
||||
return GetProfileNames()
|
||||
.Select(GetRequiredProfile)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<LaunchProfileHotkey> CreateHotkeys(string profileName)
|
||||
{
|
||||
var profile = GetRequiredProfile(profileName);
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<OutputType>WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\meeting-assistant.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MeetingAssistant.Tests" />
|
||||
</ItemGroup>
|
||||
@@ -25,11 +30,32 @@
|
||||
<PackageReference Include="YamlDotNet" Version="17.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0-windows'">
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<PackageReference Include="Aprillz.MewUI.Windows" Version="0.15.2" />
|
||||
<PackageReference Include="H.NotifyIcon.Uno.WinUI" Version="2.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Update="appsettings*.json" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="%(Filename)%(Extension)" />
|
||||
<None Update="Assets\meeting-assistant.ico" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="..\docs\meeting-workflow-engine.md" Link="docs\meeting-workflow-engine.md" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MeetingAssistantRootConfig Include="$(MSBuildProjectDirectory)\appsettings*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyMeetingAssistantConfigToPublishRoot" AfterTargets="Publish">
|
||||
<Copy SourceFiles="@(MeetingAssistantRootConfig)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) != 'windows'">
|
||||
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
<Compile Remove="Taskbar\UnoTaskbarIconService.Windows.cs" />
|
||||
<Compile Remove="Workflow\MewUiWorkflowRulesEditorWindowService.Windows.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class MeetingAssistantOptions
|
||||
|
||||
public AgentOptions Agent { get; set; } = new();
|
||||
|
||||
public WorkflowRulesEditorOptions WorkflowRulesEditor { get; set; } = new();
|
||||
|
||||
public ApiOptions Api { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ public sealed class ScreenshotOcrOptions
|
||||
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.
|
||||
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 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.
|
||||
@@ -83,7 +86,7 @@ public sealed class HotkeyOptions
|
||||
{
|
||||
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
|
||||
@@ -309,7 +312,7 @@ public sealed class AgentOptions
|
||||
|
||||
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;
|
||||
|
||||
@@ -332,6 +335,60 @@ public sealed class AgentOptions
|
||||
public string? InitialPrompt { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorOptions
|
||||
{
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public string? Key { get; set; }
|
||||
|
||||
public string? KeyEnv { get; set; }
|
||||
|
||||
public string? Model { get; set; }
|
||||
|
||||
public bool? EnableThinking { get; set; }
|
||||
|
||||
public ReasoningEffortOption? ReasoningEffort { get; set; }
|
||||
|
||||
public int? ReconnectionAttempts { get; set; }
|
||||
|
||||
public TimeSpan? ReconnectionDelay { get; set; }
|
||||
|
||||
public int? ContextWindowTokens { get; set; }
|
||||
|
||||
public int? MaxOutputTokens { get; set; }
|
||||
|
||||
public bool? EnableCompaction { get; set; }
|
||||
|
||||
public double? CompactionRemainingRatio { get; set; }
|
||||
|
||||
public string? ResponsesCompactPath { get; set; }
|
||||
|
||||
public string? InitialPrompt { get; set; }
|
||||
|
||||
public AgentOptions ToEffectiveAgentOptions(AgentOptions defaults)
|
||||
{
|
||||
return new AgentOptions
|
||||
{
|
||||
Endpoint = string.IsNullOrWhiteSpace(Endpoint) ? defaults.Endpoint : Endpoint,
|
||||
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
|
||||
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
|
||||
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
|
||||
EnableThinking = EnableThinking ?? defaults.EnableThinking,
|
||||
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
|
||||
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
|
||||
ReconnectionDelay = ReconnectionDelay ?? defaults.ReconnectionDelay,
|
||||
ContextWindowTokens = ContextWindowTokens ?? defaults.ContextWindowTokens,
|
||||
MaxOutputTokens = MaxOutputTokens ?? defaults.MaxOutputTokens,
|
||||
EnableCompaction = EnableCompaction ?? defaults.EnableCompaction,
|
||||
CompactionRemainingRatio = CompactionRemainingRatio ?? defaults.CompactionRemainingRatio,
|
||||
ResponsesCompactPath = string.IsNullOrWhiteSpace(ResponsesCompactPath)
|
||||
? defaults.ResponsesCompactPath
|
||||
: ResponsesCompactPath!,
|
||||
InitialPrompt = string.IsNullOrWhiteSpace(InitialPrompt) ? null : InitialPrompt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ApiOptions
|
||||
{
|
||||
public string PublicBaseUrl { get; set; } = "http://localhost:5090";
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class MeetingArtifactFrontmatter
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public string? OneLiner { get; set; }
|
||||
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
@@ -38,6 +40,7 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("---");
|
||||
AppendScalar(builder, "title", frontmatter.Title);
|
||||
AppendScalarIfNotEmpty(builder, "oneliner", frontmatter.OneLiner);
|
||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
|
||||
@@ -124,6 +127,16 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
|
||||
}
|
||||
|
||||
private static void AppendScalarIfNotEmpty(StringBuilder builder, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppendScalar(builder, key, value);
|
||||
}
|
||||
|
||||
private static void AppendQuoted(StringBuilder builder, string key, string value)
|
||||
{
|
||||
builder.Append(key);
|
||||
|
||||
+35
-21
@@ -6,6 +6,7 @@ using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Taskbar;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -57,8 +58,18 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
|
||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorInstructionBuilder, WorkflowRulesEditorInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorSamplePlaybackQueue, WorkflowRulesEditorSamplePlaybackQueue>();
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorChatPipeline, WorkflowRulesEditorChatPipeline>();
|
||||
builder.Services.AddTransient<WorkflowRulesEditorChatViewModel>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, MewUiWorkflowRulesEditorWindowService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IWorkflowRulesEditorWindowService, NoopWorkflowRulesEditorWindowService>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
@@ -79,6 +90,7 @@ builder.Services.AddHostedService<SpeakerIdentityDatabaseInitializer>();
|
||||
builder.Services.AddHostedService<SpeechRecognitionPipelineHostedService>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddHostedService<GlobalHotkeyService>();
|
||||
builder.Services.AddHostedService<UnoTaskbarIconService>();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -210,6 +222,12 @@ static async Task<IResult> MergeSpeakerIdentitiesAsync(
|
||||
}
|
||||
app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) =>
|
||||
Results.Ok(ReloadWorkflowConfiguration(configuration)));
|
||||
app.MapPost("/diagnostics/workflow/rules-editor/show", (
|
||||
IWorkflowRulesEditorWindowService rulesEditorWindow) =>
|
||||
{
|
||||
rulesEditorWindow.Show();
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
|
||||
{
|
||||
@@ -298,61 +316,53 @@ static async Task<IResult> RunCurrentSummaryAsync(
|
||||
|
||||
app.MapPost("/meetings/summary/retry", async (
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
app.MapGet("/meetings/summary/retry", async (
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
@@ -360,8 +370,7 @@ app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
static async Task<IResult> RetrySummaryAsync(
|
||||
string? launchProfile,
|
||||
string? summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -372,13 +381,18 @@ static async Task<IResult> RetrySummaryAsync(
|
||||
}
|
||||
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken);
|
||||
if (artifacts is null)
|
||||
var trigger = await summaryRetryRunner.TriggerAsync(summaryPath, profileOptions, cancellationToken);
|
||||
if (trigger is null)
|
||||
{
|
||||
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 (
|
||||
|
||||
@@ -82,12 +82,26 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentArtifacts?.TranscriptPath ?? currentSession?.TranscriptPath,
|
||||
currentArtifacts?.MeetingNotePath ?? currentMeetingNote?.Path,
|
||||
currentArtifacts?.AssistantContextPath,
|
||||
currentArtifacts?.SummaryPath);
|
||||
currentArtifacts?.SummaryPath,
|
||||
GetProcessState(currentRun),
|
||||
currentRun?.LaunchProfileName);
|
||||
|
||||
public MeetingSessionArtifacts? CurrentArtifacts => currentArtifacts;
|
||||
|
||||
private bool IsRecording => currentRun is { IsCaptureStopping: false };
|
||||
|
||||
private static RecordingProcessState GetProcessState(RecordingRun? run)
|
||||
{
|
||||
if (run is null)
|
||||
{
|
||||
return RecordingProcessState.Idle;
|
||||
}
|
||||
|
||||
return run.IsCaptureStopping
|
||||
? RecordingProcessState.Summarizing
|
||||
: RecordingProcessState.Recording;
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> ToggleAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await ToggleAsync(null, cancellationToken);
|
||||
@@ -97,9 +111,24 @@ public sealed class MeetingRecordingCoordinator
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return IsRecording
|
||||
? await StopAsync(cancellationToken)
|
||||
: await StartAsync(launchProfileName, cancellationToken);
|
||||
if (!IsRecording)
|
||||
{
|
||||
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)
|
||||
@@ -119,7 +148,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
var runOptions = ResolveOptions(launchProfileName);
|
||||
var launchProfile = ResolveProfile(launchProfileName);
|
||||
var runOptions = launchProfile.Options;
|
||||
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||
var startedAt = DateTimeOffset.Now;
|
||||
@@ -173,6 +203,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
pipeline,
|
||||
pipelineOptions,
|
||||
runOptions,
|
||||
launchProfile.Name,
|
||||
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
|
||||
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
||||
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
||||
@@ -318,12 +349,70 @@ public sealed class MeetingRecordingCoordinator
|
||||
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)
|
||||
{
|
||||
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
||||
var liveTranscriptTask = Task.Run(
|
||||
() => StoreLiveTranscriptAsync(run, run.TranscriptionCancellation),
|
||||
CancellationToken.None);
|
||||
run.AddLiveTranscriptTask(Task.Run(
|
||||
() => StoreLiveTranscriptAsync(run, run.Pipeline, run.TranscriptionCancellation),
|
||||
CancellationToken.None));
|
||||
var captureTask = Task.Run(
|
||||
() => CaptureAudioAsync(run),
|
||||
CancellationToken.None);
|
||||
@@ -335,7 +424,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
await captureTask;
|
||||
await run.Pipeline.CompleteAsync(run.TranscriptionCancellation);
|
||||
await liveTranscriptTask;
|
||||
await run.WaitForLiveTranscriptTasksAsync(run.TranscriptionCancellation);
|
||||
run.CancelLiveIdentification();
|
||||
await liveIdentificationTask;
|
||||
await run.RecordedAudio.DisposeAsync();
|
||||
@@ -366,7 +455,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
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)
|
||||
{
|
||||
@@ -383,7 +474,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
run.CancelLiveIdentification();
|
||||
await run.RecordedAudio.DeleteAsync(CancellationToken.None);
|
||||
await run.Pipeline.DisposeAsync();
|
||||
await run.DisposePipelinesAsync();
|
||||
await CompleteRunAsync(run);
|
||||
}
|
||||
}
|
||||
@@ -407,9 +498,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
chunk.Channels);
|
||||
}
|
||||
|
||||
run.AppendAudio(chunk);
|
||||
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
|
||||
await run.Pipeline.WriteAsync(chunk, run.CaptureCancellation);
|
||||
await run.WriteAudioAsync(chunk, run.CaptureCancellation);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -432,9 +522,10 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
private async Task StoreLiveTranscriptAsync(
|
||||
RecordingRun run,
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in run.Pipeline.ReadLiveTranscriptAsync(cancellationToken))
|
||||
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
|
||||
{
|
||||
run.TryAddSpeakerSample(segment);
|
||||
var relabeledSegment = run.Relabel(segment);
|
||||
@@ -716,6 +807,18 @@ public sealed class MeetingRecordingCoordinator
|
||||
RecordingRun run,
|
||||
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(
|
||||
run.RecordedAudio.AudioPath,
|
||||
await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken),
|
||||
@@ -728,19 +831,34 @@ public sealed class MeetingRecordingCoordinator
|
||||
finishedSegments = finishedSegments
|
||||
.Select(run.Relabel)
|
||||
.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,
|
||||
finishedSegments,
|
||||
run.GetSpeakerSamplesSnapshot(),
|
||||
run.GetSpeakerMappingsSnapshot(),
|
||||
samples,
|
||||
knownSpeakerMappings,
|
||||
run.Artifacts,
|
||||
run.MeetingNotePath,
|
||||
run.Options,
|
||||
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,
|
||||
IReadOnlyList<TranscriptionSegment> finishedSegments,
|
||||
IReadOnlyList<SpeakerAudioSample> samples,
|
||||
@@ -752,13 +870,13 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath))
|
||||
{
|
||||
return finishedSegments;
|
||||
return new SpeakerIdentificationResult(finishedSegments, new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
||||
var result = await speakerIdentificationService.IdentifyFinishedSpeakersAsync(
|
||||
new SpeakerIdentificationRequest(
|
||||
audioPath,
|
||||
meetingNote,
|
||||
@@ -772,7 +890,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
meetingNotePath,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
return result.Segments;
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -780,8 +898,92 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Speaker identity learning failed; keeping ASR speaker labels");
|
||||
return finishedSegments;
|
||||
logger.LogWarning(exception, "Finished speaker identity matching failed; keeping ASR speaker labels");
|
||||
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 +1145,13 @@ public sealed class MeetingRecordingCoordinator
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RunSummaryAsync(
|
||||
private async Task<AssistantContextState> RunSummaryAsync(
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
return AssistantContextState.Finished;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -963,10 +1165,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
AssistantContextState.Summarizing,
|
||||
cancellationToken);
|
||||
var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken);
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
return result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -975,10 +1174,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
return AssistantContextState.Error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,17 +1231,17 @@ public sealed class MeetingRecordingCoordinator
|
||||
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
||||
}
|
||||
|
||||
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
||||
private LaunchProfile ResolveProfile(string? launchProfileName)
|
||||
{
|
||||
if (launchProfiles is not null)
|
||||
{
|
||||
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
||||
return launchProfiles.GetRequiredProfile(launchProfileName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
||||
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return options;
|
||||
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
@@ -1102,11 +1298,15 @@ public sealed class MeetingRecordingCoordinator
|
||||
private int completed;
|
||||
private readonly object stateGate = new();
|
||||
private readonly object liveSegmentsGate = new();
|
||||
private readonly object liveTranscriptTasksGate = new();
|
||||
private readonly object liveIdentificationGate = new();
|
||||
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 SpeakerAudioSampleCollector speakerSampleCollector;
|
||||
private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint;
|
||||
private FinalSpeakerIdentificationWork? pendingFinalSpeakerIdentification;
|
||||
|
||||
public RecordingRun(
|
||||
CancellationTokenSource captureCancellation,
|
||||
@@ -1118,6 +1318,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||
MeetingAssistantOptions options,
|
||||
string launchProfileName,
|
||||
TimeSpan liveSampleBufferDuration,
|
||||
int maxSpeakerSamples)
|
||||
{
|
||||
@@ -1131,6 +1332,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
Pipeline = pipeline;
|
||||
PipelineOptions = pipelineOptions;
|
||||
Options = options;
|
||||
LaunchProfileName = launchProfileName;
|
||||
pipelines.Add(pipeline);
|
||||
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
|
||||
}
|
||||
|
||||
@@ -1148,11 +1351,15 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
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;
|
||||
|
||||
@@ -1166,6 +1373,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public bool IsAborted { get; private set; }
|
||||
|
||||
public bool HasSwitchedProfile { get; private set; }
|
||||
|
||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||
|
||||
public void StopCapture()
|
||||
@@ -1191,6 +1400,103 @@ public sealed class MeetingRecordingCoordinator
|
||||
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(
|
||||
AssistantContextState state,
|
||||
out AssistantContextState fromState)
|
||||
@@ -1340,6 +1646,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
CaptureCancellationSource.Dispose();
|
||||
TranscriptionCancellationSource.Dispose();
|
||||
LiveIdentificationCancellationSource.Dispose();
|
||||
PipelineGate.Dispose();
|
||||
}
|
||||
|
||||
private static int StateRank(AssistantContextState state)
|
||||
@@ -1367,6 +1674,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
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
|
||||
@@ -1388,4 +1701,13 @@ public sealed record RecordingStatus(
|
||||
string? TranscriptPath,
|
||||
string? MeetingNotePath,
|
||||
string? AssistantContextPath,
|
||||
string? SummaryPath);
|
||||
string? SummaryPath,
|
||||
RecordingProcessState State = RecordingProcessState.Idle,
|
||||
string? LaunchProfile = null);
|
||||
|
||||
public enum RecordingProcessState
|
||||
{
|
||||
Idle,
|
||||
Recording,
|
||||
Summarizing
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
if (end <= start)
|
||||
|
||||
@@ -21,6 +21,15 @@ internal sealed class SpeakerAudioSampleCollector
|
||||
audioBuffer.Append(chunk);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
samplesBySpeaker.Clear();
|
||||
audioBuffer.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void TryAdd(TranscriptionSegment segment)
|
||||
{
|
||||
if (!IsDiarizedSpeaker(segment.Speaker))
|
||||
|
||||
@@ -59,6 +59,29 @@ public interface ISpeakerIdentificationService
|
||||
SpeakerIdentificationRequest request,
|
||||
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(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -110,7 +110,10 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
|
||||
var targetName = target.GetDisplayName() ?? $"identity-{target.Id}";
|
||||
var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}";
|
||||
MergeIdentities(target, source);
|
||||
SpeakerIdentityMerger.MergeInto(
|
||||
target,
|
||||
source,
|
||||
options.MaxSnippetsPerSpeaker);
|
||||
await SpeakerIdentityTranscriptAudit.AppendMergedAsync(
|
||||
target.References,
|
||||
targetName,
|
||||
@@ -161,60 +164,4 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
.FirstOrDefault(snippet => excludedSnippet is null || snippet.Id != excludedSnippet.Id);
|
||||
}
|
||||
|
||||
private void MergeIdentities(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);
|
||||
}
|
||||
|
||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
foreach (var reference in source.References)
|
||||
{
|
||||
SpeakerIdentityReferences.AddIfMissing(target, reference);
|
||||
}
|
||||
|
||||
var retainedSnippets = target.Snippets
|
||||
.Concat(source.Snippets)
|
||||
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
|
||||
.Take(Math.Max(1, options.MaxSnippetsPerSpeaker))
|
||||
.Select(snippet => new SpeakerSnippet
|
||||
{
|
||||
WavBytes = snippet.WavBytes,
|
||||
CreatedAt = snippet.CreatedAt
|
||||
})
|
||||
.ToList();
|
||||
target.Snippets.Clear();
|
||||
target.Snippets.AddRange(retainedSnippets);
|
||||
}
|
||||
|
||||
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 int StableSnippetKey(byte[] snippet)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var value in snippet)
|
||||
{
|
||||
hash.Add(value);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
internal static class SpeakerIdentityMerger
|
||||
{
|
||||
public static void MergeInto(
|
||||
SpeakerIdentity target,
|
||||
SpeakerIdentity source,
|
||||
int maxSnippets)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
var retainedSnippets = target.Snippets
|
||||
.Concat(source.Snippets)
|
||||
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
|
||||
.Take(Math.Max(1, maxSnippets))
|
||||
.Select(snippet => new SpeakerSnippet
|
||||
{
|
||||
WavBytes = snippet.WavBytes,
|
||||
CreatedAt = snippet.CreatedAt
|
||||
})
|
||||
.ToList();
|
||||
target.Snippets.Clear();
|
||||
target.Snippets.AddRange(retainedSnippets);
|
||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
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 int StableSnippetKey(byte[] snippet)
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (var value in snippet)
|
||||
{
|
||||
hash.Add(value);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,94 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
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(
|
||||
SpeakerIdentificationRequest request,
|
||||
SpeakerIdentityProcessingMode mode,
|
||||
@@ -250,6 +338,115 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
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(
|
||||
SpeakerIdentity identity,
|
||||
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 LiteLlmResponsesCompactionOptions? compactionOptions;
|
||||
private readonly ILogger? logger;
|
||||
private int responsesRequestCount;
|
||||
|
||||
public LiteLlmResponsesChatClient(
|
||||
Uri endpoint,
|
||||
@@ -31,7 +32,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
int reconnectionAttempts,
|
||||
TimeSpan reconnectionDelay,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null)
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true)
|
||||
: this(
|
||||
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
|
||||
apiKey,
|
||||
@@ -41,7 +43,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
reconnectionAttempts,
|
||||
reconnectionDelay,
|
||||
compactionOptions,
|
||||
logger)
|
||||
logger,
|
||||
firstRequestIsUser)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -54,7 +57,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
int reconnectionAttempts,
|
||||
TimeSpan reconnectionDelay,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null)
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
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.compactionOptions = compactionOptions;
|
||||
this.logger = logger;
|
||||
responsesRequestCount = firstRequestIsUser ? 0 : 1;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -81,6 +86,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = ParseResponseJson(responseJson);
|
||||
LogResponseDiagnostics(responseJson, response);
|
||||
ThrowIfResponseHasNoContent(responseJson, response);
|
||||
LogResponseUsage(response.Usage);
|
||||
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 response = await httpClient.PostAsync(
|
||||
NormalizeRelativePath(compactionOptions.CompactPath),
|
||||
content,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var requestMessage = CreateJsonRequest(
|
||||
NormalizeRelativePath(compactionOptions.CompactPath),
|
||||
request.ToJsonString(JsonOptions),
|
||||
"agent");
|
||||
using var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
|
||||
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
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);
|
||||
if (tools.Count > 0)
|
||||
{
|
||||
@@ -338,8 +349,13 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
{
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
using var response = await httpClient.PostAsync("responses", content, cancellationToken).ConfigureAwait(false);
|
||||
var initiator = NextInitiator();
|
||||
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);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
@@ -367,6 +383,28 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
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)
|
||||
{
|
||||
if (reconnectionDelay > TimeSpan.Zero)
|
||||
@@ -423,6 +461,110 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
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)
|
||||
{
|
||||
var role = message.Role.Value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
@@ -12,14 +13,15 @@ public interface IMeetingSummaryArtifactResolver
|
||||
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
|
||||
}
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
|
||||
{
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
|
||||
@@ -49,6 +51,14 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
return null;
|
||||
}
|
||||
|
||||
var summaryArtifacts = await ResolveFromSummaryFrontmatterAsync(
|
||||
requestedSummaryPath,
|
||||
cancellationToken);
|
||||
if (summaryArtifacts is not null)
|
||||
{
|
||||
return summaryArtifacts;
|
||||
}
|
||||
|
||||
var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||
if (!Directory.Exists(meetingNotesFolder))
|
||||
{
|
||||
@@ -64,16 +74,77 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
continue;
|
||||
}
|
||||
|
||||
return new MeetingSessionArtifacts(
|
||||
return TryCreateArtifactsFromLinks(
|
||||
note.Path,
|
||||
ResolveNoteLink(note.Frontmatter.Transcript, note.Path),
|
||||
ResolveNoteLink(note.Frontmatter.AssistantContext, note.Path),
|
||||
requestedSummaryPath);
|
||||
requestedSummaryPath,
|
||||
note.Frontmatter.Transcript,
|
||||
note.Frontmatter.AssistantContext);
|
||||
}
|
||||
|
||||
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(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options)
|
||||
@@ -141,4 +212,16 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
var normalizedPath = Path.GetFullPath(path);
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,14 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
|
||||
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important.
|
||||
If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
@@ -94,8 +95,130 @@ public sealed class MeetingSummaryTools
|
||||
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
||||
}
|
||||
|
||||
public async Task<string> WriteSummary(string markdown, string? title = null)
|
||||
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 oneliner, string? title = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(oneliner))
|
||||
{
|
||||
return "Refused: oneliner must not be empty.";
|
||||
}
|
||||
|
||||
var trimmedOneLiner = oneliner.Trim();
|
||||
if (ContainsLineBreak(trimmedOneLiner))
|
||||
{
|
||||
return "Refused: oneliner must be a single line.";
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
var meetingNote = await ReadMeetingNoteAsync();
|
||||
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
@@ -106,12 +229,22 @@ public sealed class MeetingSummaryTools
|
||||
meetingNote,
|
||||
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
||||
CancellationToken.None);
|
||||
frontmatter.OneLiner = trimmedOneLiner;
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
||||
SummaryWasWritten = true;
|
||||
return artifacts.SummaryPath;
|
||||
}
|
||||
|
||||
private static bool ContainsLineBreak(string value)
|
||||
{
|
||||
return value.Contains('\n', StringComparison.Ordinal) ||
|
||||
value.Contains('\r', StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public bool SummaryWasWritten { get; private set; }
|
||||
|
||||
public async Task<string> WriteContext(
|
||||
string content,
|
||||
int? @from = null,
|
||||
@@ -124,10 +257,12 @@ public sealed class MeetingSummaryTools
|
||||
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)!);
|
||||
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||
? await File.ReadAllTextAsync(artifacts.AssistantContextPath)
|
||||
: "";
|
||||
if (!File.Exists(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 updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
|
||||
var updated = string.IsNullOrWhiteSpace(frontmatter)
|
||||
@@ -267,6 +402,64 @@ public sealed class MeetingSummaryTools
|
||||
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)
|
||||
{
|
||||
var boundProjects = await GetBoundProjectsAsync();
|
||||
|
||||
@@ -51,7 +51,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
var agentOptions = options.Agent;
|
||||
var key = ResolveApiKey(agentOptions);
|
||||
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);
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
@@ -62,7 +63,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions: null,
|
||||
logger);
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
@@ -73,7 +75,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger);
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var agent = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation()
|
||||
@@ -93,6 +96,10 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
session: null,
|
||||
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
|
||||
cancellationToken: cancellationToken);
|
||||
if (!meetingTools.SummaryWasWritten)
|
||||
{
|
||||
throw CreateMissingSummaryWriteException(response);
|
||||
}
|
||||
|
||||
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
|
||||
@@ -145,6 +152,22 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
tools.AddDictationWord,
|
||||
"add_dictation_word",
|
||||
"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(
|
||||
tools.WriteSummary,
|
||||
"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)
|
||||
{
|
||||
var chatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = options.Model,
|
||||
MaxOutputTokens = options.MaxOutputTokens,
|
||||
AllowMultipleToolCalls = true,
|
||||
ToolMode = ChatToolMode.Auto
|
||||
};
|
||||
|
||||
@@ -60,10 +60,12 @@ public sealed class SummaryAgentWriteAudit
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken)
|
||||
: "";
|
||||
if (!File.Exists(artifacts.AssistantContextPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
|
||||
var builder = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Taskbar;
|
||||
|
||||
public enum MeetingTaskbarAction
|
||||
{
|
||||
EditRules,
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
AbortRecording,
|
||||
SwitchProfile
|
||||
}
|
||||
|
||||
public sealed record MeetingTaskbarMenu(
|
||||
RecordingProcessState State,
|
||||
string Tooltip,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem> Items);
|
||||
|
||||
public sealed record MeetingTaskbarMenuItem(
|
||||
string Text,
|
||||
MeetingTaskbarAction Action,
|
||||
string? ProfileName = null);
|
||||
|
||||
public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
public static MeetingTaskbarMenu Build(
|
||||
RecordingStatus status,
|
||||
IReadOnlyList<LaunchProfile> launchProfiles)
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>
|
||||
{
|
||||
new("Edit rules and identities", MeetingTaskbarAction.EditRules)
|
||||
};
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Stop meeting recording and transcribe",
|
||||
MeetingTaskbarAction.StopRecording));
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Cancel meeting recording and discard",
|
||||
MeetingTaskbarAction.AbortRecording));
|
||||
|
||||
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
AppendHotkey($"Switch to {profile.Name}", profile.Options.Hotkey.Toggle),
|
||||
MeetingTaskbarAction.SwitchProfile,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var profile in launchProfiles)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
AppendHotkey($"Start meeting recording ({profile.Name})", profile.Options.Hotkey.Toggle),
|
||||
MeetingTaskbarAction.StartRecording,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
|
||||
return new MeetingTaskbarMenu(
|
||||
status.State,
|
||||
BuildTooltip(status),
|
||||
items);
|
||||
}
|
||||
|
||||
private static string BuildTooltip(RecordingStatus status)
|
||||
{
|
||||
return status.State switch
|
||||
{
|
||||
RecordingProcessState.Recording => string.IsNullOrWhiteSpace(status.LaunchProfile)
|
||||
? "Meeting Assistant - recording"
|
||||
: $"Meeting Assistant - recording ({status.LaunchProfile})",
|
||||
RecordingProcessState.Summarizing => "Meeting Assistant - summarizing",
|
||||
_ => "Meeting Assistant - idle"
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsActiveProfile(
|
||||
LaunchProfile profile,
|
||||
RecordingStatus status)
|
||||
{
|
||||
return string.Equals(
|
||||
profile.Name,
|
||||
status.LaunchProfile,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string AppendHotkey(string text, string hotkey)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(hotkey)
|
||||
? text
|
||||
: $"{text}\t{hotkey.Trim()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
#if WINDOWS
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using H.NotifyIcon.Core;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Workflow;
|
||||
|
||||
namespace MeetingAssistant.Taskbar;
|
||||
|
||||
public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
{
|
||||
private static readonly Guid TrayIconId = Guid.Parse("91F3D8B7-E61F-4E73-98F5-29A665991C67");
|
||||
|
||||
private readonly MeetingRecordingCoordinator coordinator;
|
||||
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
||||
private readonly IWorkflowRulesEditorWindowService rulesEditorWindow;
|
||||
private readonly ILogger<UnoTaskbarIconService> logger;
|
||||
private readonly object sync = new();
|
||||
private CancellationTokenSource? refreshCancellation;
|
||||
private Task? refreshTask;
|
||||
private TrayIconWithContextMenu? trayIcon;
|
||||
private Icon? currentIcon;
|
||||
private RecordingProcessState? currentState;
|
||||
private string? currentProfilesSignature;
|
||||
private string? currentMenuSignature;
|
||||
|
||||
public UnoTaskbarIconService(
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
IWorkflowRulesEditorWindowService rulesEditorWindow,
|
||||
ILogger<UnoTaskbarIconService> logger)
|
||||
{
|
||||
this.coordinator = coordinator;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.rulesEditorWindow = rulesEditorWindow;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
logger.LogInformation("Taskbar icon is only supported on Windows");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var menu = BuildMenu();
|
||||
currentMenuSignature = BuildMenuSignature(menu);
|
||||
currentIcon = CreateIcon(menu.State);
|
||||
currentState = menu.State;
|
||||
trayIcon = new TrayIconWithContextMenu(TrayIconId)
|
||||
{
|
||||
Icon = currentIcon.Handle,
|
||||
ToolTip = menu.Tooltip,
|
||||
ContextMenu = BuildContextMenu(menu)
|
||||
};
|
||||
trayIcon.Created += (_, _) => logger.LogInformation(
|
||||
"Taskbar icon native create event received: isCreated={IsCreated}",
|
||||
trayIcon.IsCreated);
|
||||
trayIcon.Removed += (_, _) => logger.LogInformation("Taskbar icon native remove event received");
|
||||
trayIcon.Create();
|
||||
trayIcon.Show();
|
||||
logger.LogInformation(
|
||||
"Taskbar icon created through H.NotifyIcon: isCreated={IsCreated}, state={State}, tooltip={Tooltip}",
|
||||
trayIcon.IsCreated,
|
||||
menu.State,
|
||||
menu.Tooltip);
|
||||
|
||||
refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
refreshTask = Task.Run(() => RefreshLoopAsync(refreshCancellation.Token), CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
refreshCancellation?.Cancel();
|
||||
if (refreshTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await refreshTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
DisposeTrayIcon();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
refreshCancellation?.Cancel();
|
||||
refreshCancellation?.Dispose();
|
||||
DisposeTrayIcon();
|
||||
}
|
||||
|
||||
private async Task RefreshLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
RefreshVisualState();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Taskbar icon refresh failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVisualState()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (trayIcon is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var menu = BuildMenu();
|
||||
var menuSignature = BuildMenuSignature(menu);
|
||||
if (currentState != menu.State)
|
||||
{
|
||||
var previousIcon = currentIcon;
|
||||
var nextIcon = CreateIcon(menu.State);
|
||||
if (!trayIcon.UpdateIcon(nextIcon.Handle))
|
||||
{
|
||||
logger.LogWarning("Taskbar icon update returned false for state {State}", menu.State);
|
||||
}
|
||||
|
||||
currentIcon = nextIcon;
|
||||
currentState = menu.State;
|
||||
previousIcon?.Dispose();
|
||||
logger.LogInformation("Taskbar icon state changed to {State}", menu.State);
|
||||
}
|
||||
|
||||
trayIcon.UpdateToolTip(menu.Tooltip);
|
||||
if (!string.Equals(currentMenuSignature, menuSignature, StringComparison.Ordinal))
|
||||
{
|
||||
trayIcon.ContextMenu = BuildContextMenu(menu);
|
||||
currentMenuSignature = menuSignature;
|
||||
logger.LogInformation("Taskbar menu changed: {MenuItems}", menuSignature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
trayIcon?.Dispose();
|
||||
trayIcon = null;
|
||||
currentIcon?.Dispose();
|
||||
currentIcon = null;
|
||||
currentState = null;
|
||||
currentMenuSignature = null;
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingTaskbarMenu BuildMenu()
|
||||
{
|
||||
var profiles = launchProfiles.GetProfiles();
|
||||
var profilesSignature = string.Join(
|
||||
", ",
|
||||
profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}"));
|
||||
if (!string.Equals(currentProfilesSignature, profilesSignature, StringComparison.Ordinal))
|
||||
{
|
||||
currentProfilesSignature = profilesSignature;
|
||||
logger.LogInformation("Taskbar menu launch profiles: {LaunchProfiles}", profilesSignature);
|
||||
}
|
||||
|
||||
return MeetingTaskbarMenuBuilder.Build(
|
||||
coordinator.CurrentStatus,
|
||||
profiles);
|
||||
}
|
||||
|
||||
private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu)
|
||||
{
|
||||
var popupMenu = new PopupMenu();
|
||||
for (var index = 0; index < menu.Items.Count; index++)
|
||||
{
|
||||
if (index == 1)
|
||||
{
|
||||
popupMenu.Items.Add(new PopupMenuSeparator());
|
||||
}
|
||||
|
||||
var menuItem = menu.Items[index];
|
||||
popupMenu.Items.Add(new PopupMenuItem(
|
||||
menuItem.Text,
|
||||
(_, _) => _ = ExecuteMenuItemAsync(menuItem)));
|
||||
}
|
||||
|
||||
return popupMenu;
|
||||
}
|
||||
|
||||
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Taskbar menu action {Action} selected for launch profile {LaunchProfile}",
|
||||
item.Action,
|
||||
item.ProfileName);
|
||||
|
||||
switch (item.Action)
|
||||
{
|
||||
case MeetingTaskbarAction.EditRules:
|
||||
rulesEditorWindow.Show();
|
||||
break;
|
||||
case MeetingTaskbarAction.StartRecording:
|
||||
await coordinator.StartAsync(item.ProfileName, CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.StopRecording:
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.AbortRecording:
|
||||
await coordinator.AbortAsync(CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.SwitchProfile:
|
||||
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
|
||||
break;
|
||||
}
|
||||
|
||||
RefreshVisualState();
|
||||
logger.LogInformation(
|
||||
"Taskbar menu action {Action} completed for launch profile {LaunchProfile}",
|
||||
item.Action,
|
||||
item.ProfileName);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Taskbar menu action {Action} failed for launch profile {LaunchProfile}",
|
||||
item.Action,
|
||||
item.ProfileName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildMenuSignature(MeetingTaskbarMenu menu)
|
||||
{
|
||||
return string.Join(
|
||||
"|",
|
||||
menu.Items.Select(item => $"{item.Action}:{item.ProfileName}:{item.Text}"));
|
||||
}
|
||||
|
||||
private static Icon CreateIcon(RecordingProcessState state)
|
||||
{
|
||||
var (color, text) = state switch
|
||||
{
|
||||
RecordingProcessState.Recording => (Color.FromArgb(220, 38, 38), "R"),
|
||||
RecordingProcessState.Summarizing => (Color.FromArgb(37, 99, 235), "S"),
|
||||
_ => (Color.FromArgb(75, 85, 99), "I")
|
||||
};
|
||||
|
||||
using var bitmap = new Bitmap(16, 16);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, 1, 1, 14, 14);
|
||||
using var font = new Font(FontFamily.GenericSansSerif, 8, FontStyle.Bold, GraphicsUnit.Pixel);
|
||||
using var textBrush = new SolidBrush(Color.White);
|
||||
var size = graphics.MeasureString(text, font);
|
||||
graphics.DrawString(
|
||||
text,
|
||||
font,
|
||||
textBrush,
|
||||
(16 - size.Width) / 2,
|
||||
(16 - size.Height) / 2);
|
||||
}
|
||||
|
||||
var handle = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
return (Icon)Icon.FromHandle(handle).Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(handle);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
}
|
||||
#endif
|
||||
@@ -32,7 +32,12 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
|
||||
return [];
|
||||
}
|
||||
|
||||
var path = ResolvePath(options.Automation.RulesPath);
|
||||
var path = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
if (path is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
|
||||
@@ -49,12 +54,4 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
|
||||
?? new MeetingWorkflowRulesFile();
|
||||
return rulesFile.Rules;
|
||||
}
|
||||
|
||||
private static string ResolvePath(string configuredPath)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorWindowService
|
||||
{
|
||||
void Show();
|
||||
}
|
||||
|
||||
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly ILogger<NoopWorkflowRulesEditorWindowService> logger;
|
||||
|
||||
public NoopWorkflowRulesEditorWindowService(ILogger<NoopWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is only available on Windows");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
#if WINDOWS
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly IServiceProvider services;
|
||||
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
private bool isRunning;
|
||||
|
||||
public MewUiWorkflowRulesEditorWindowService(
|
||||
IServiceProvider services,
|
||||
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.services = services;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
}
|
||||
|
||||
logger.LogInformation("Starting workflow rules editor UI thread");
|
||||
var thread = new Thread(RunWindow)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Rules Editor"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private void RunWindow()
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI thread started");
|
||||
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
|
||||
var window = new WorkflowRulesEditorMewWindow(viewModel);
|
||||
window.Run();
|
||||
logger.LogInformation("Workflow rules editor UI window closed");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Workflow rules editor UI failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRulesEditorMewWindow
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly StackPanel conversationPanel = new();
|
||||
private readonly ScrollViewer conversationScroll = new();
|
||||
private readonly MultiLineTextBox input = new();
|
||||
private readonly Button sendButton = new();
|
||||
private readonly EventHandler changedHandler;
|
||||
|
||||
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
changedHandler = (_, _) => RequestRender();
|
||||
this.viewModel.Changed += changedHandler;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
ConfigureTheme();
|
||||
|
||||
conversationPanel
|
||||
.Vertical()
|
||||
.Spacing(8);
|
||||
input
|
||||
.Height(92)
|
||||
.Wrap(true)
|
||||
.Placeholder("Ask to make a rule or to list identities")
|
||||
.OnTextChanged(text => viewModel.Draft = text)
|
||||
.OnKeyDown(args =>
|
||||
{
|
||||
if (args.Key == Key.Enter && !args.ShiftKey)
|
||||
{
|
||||
args.Handled = true;
|
||||
_ = SendAsync();
|
||||
}
|
||||
});
|
||||
sendButton
|
||||
.Content("Send")
|
||||
.Width(84)
|
||||
.OnClick(() => _ = SendAsync());
|
||||
|
||||
var window = new Window()
|
||||
.Title("Edit rules and identities")
|
||||
.Resizable(680, 760, 520, 460)
|
||||
.Padding(0)
|
||||
.OnLoaded(Render)
|
||||
.OnClosed(() => viewModel.Changed -= changedHandler)
|
||||
.Content(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Padding(12)
|
||||
.Spacing(10)
|
||||
.Children(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Spacing(8)
|
||||
.DockBottom()
|
||||
.Children(
|
||||
sendButton.DockRight(),
|
||||
input),
|
||||
conversationScroll
|
||||
.AutoVerticalScroll()
|
||||
.NoHorizontalScroll()
|
||||
.Content(conversationPanel)));
|
||||
var icon = LoadWindowIcon();
|
||||
if (icon is not null)
|
||||
{
|
||||
window.Icon = icon;
|
||||
}
|
||||
|
||||
Application.Create()
|
||||
.UseTheme(ThemeVariant.Dark)
|
||||
.UseWin32()
|
||||
.UseDirect2D()
|
||||
.Run(window);
|
||||
}
|
||||
|
||||
private static IconSource? LoadWindowIcon()
|
||||
{
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
|
||||
return File.Exists(iconPath)
|
||||
? IconSource.FromFile(iconPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void ConfigureTheme()
|
||||
{
|
||||
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(245, 245, 245)
|
||||
};
|
||||
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(42, 46, 54)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
var sendTask = viewModel.SendAsync();
|
||||
input.Text = viewModel.Draft;
|
||||
await sendTask;
|
||||
}
|
||||
|
||||
private void RequestRender()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(Render);
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
conversationScroll.VerticalOffset,
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Clear();
|
||||
|
||||
foreach (var message in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Add(CreateMessageCard(message));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
conversationPanel.Add(new TextBlock()
|
||||
.Text("Thinking...")
|
||||
.Foreground(Color.FromRgb(156, 166, 181))
|
||||
.Margin(4, 2, 4, 2));
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
|
||||
if (shouldAutoScroll)
|
||||
{
|
||||
QueueScrollConversationToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueScrollConversationToBottom()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
|
||||
{
|
||||
ScrollConversationToBottom();
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
|
||||
});
|
||||
}
|
||||
|
||||
private void ScrollConversationToBottom()
|
||||
{
|
||||
conversationScroll.SetScrollOffsets(
|
||||
conversationScroll.HorizontalOffset,
|
||||
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight));
|
||||
}
|
||||
|
||||
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
|
||||
return new Border()
|
||||
.Padding(10)
|
||||
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
|
||||
.CornerRadius(8)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
|
||||
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
|
||||
.Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public enum WorkflowRulesEditorChatRole
|
||||
{
|
||||
User,
|
||||
Agent
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole Role,
|
||||
string Content);
|
||||
|
||||
public sealed record WorkflowRulesEditorChatResult(
|
||||
string Response,
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> Conversation);
|
||||
|
||||
public interface IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILoggerFactory loggerFactory;
|
||||
private readonly ILogger<WorkflowRulesEditorChatPipeline> logger;
|
||||
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
|
||||
|
||||
public WorkflowRulesEditorChatPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<WorkflowRulesEditorChatPipeline> logger,
|
||||
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
|
||||
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
this.logger = logger;
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
}
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
return new WorkflowRulesEditorChatResult("", conversation);
|
||||
}
|
||||
|
||||
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
var key = ResolveApiKey(agentOptions, "workflow rules editor");
|
||||
var tools = new WorkflowRulesEditorTools(
|
||||
options,
|
||||
speakerIdentityDbContextFactory,
|
||||
samplePlaybackQueue);
|
||||
var messages = conversation
|
||||
.Select(ToChatMessage)
|
||||
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
||||
.ToList();
|
||||
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
||||
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions: null,
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser: true);
|
||||
var functionClient = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(loggerFactory)
|
||||
.Build();
|
||||
|
||||
var response = await functionClient.GetResponseAsync(
|
||||
messages,
|
||||
CreateChatOptions(agentOptions, tools, instructions),
|
||||
cancellationToken);
|
||||
var responseText = string.IsNullOrWhiteSpace(response.Text)
|
||||
? "(No response text returned.)"
|
||||
: response.Text.Trim();
|
||||
var nextConversation = conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim()))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText))
|
||||
.ToList();
|
||||
return new WorkflowRulesEditorChatResult(responseText, nextConversation);
|
||||
}
|
||||
|
||||
private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
return new ChatMessage(
|
||||
message.Role == WorkflowRulesEditorChatRole.Agent ? ChatRole.Assistant : ChatRole.User,
|
||||
message.Content);
|
||||
}
|
||||
|
||||
private static ChatOptions CreateChatOptions(
|
||||
AgentOptions options,
|
||||
WorkflowRulesEditorTools tools,
|
||||
string instructions)
|
||||
{
|
||||
return new ChatOptions
|
||||
{
|
||||
ModelId = options.Model,
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = options.MaxOutputTokens,
|
||||
AllowMultipleToolCalls = true,
|
||||
ToolMode = ChatToolMode.Auto,
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadRules,
|
||||
"read_rules",
|
||||
"Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteRules,
|
||||
"write_rules",
|
||||
"Overwrite the configured workflow rules YAML file after validating that it parses as a workflow rules document."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.Search,
|
||||
"search",
|
||||
"Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchIdentities,
|
||||
"search_identities",
|
||||
"Search or list speaker identities. Optional query matches canonical names, aliases, and candidate names. Returns JSON summaries."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadIdentity,
|
||||
"read_identity",
|
||||
"Read one speaker identity by numeric identity id, including aliases, candidate names, references, and sample metadata."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.CreateIdentity,
|
||||
"create_identity",
|
||||
"Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.UpdateIdentity,
|
||||
"update_identity",
|
||||
"Replace a speaker identity's canonicalName, aliases, and candidateNames by numeric identity id. Omitted aliases/candidateNames become empty."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DeleteIdentity,
|
||||
"delete_identity",
|
||||
"Delete a speaker identity and all linked aliases, candidate names, references, and samples by numeric identity id."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.MergeIdentities,
|
||||
"merge_identities",
|
||||
"Merge sourceIdentityId into targetIdentityId, preserving target canonical name, adding source names as aliases, moving references and samples, then deleting the source."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListIdentitySamples,
|
||||
"list_identity_samples",
|
||||
"List audio samples for a speaker identity by numeric identity id. Returns sample ids and metadata, not audio bytes."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadIdentitySample,
|
||||
"read_identity_sample",
|
||||
"Read one audio sample by numeric sample id. Returns metadata and base64 WAV bytes."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DeleteIdentitySample,
|
||||
"delete_identity_sample",
|
||||
"Delete one audio sample by numeric sample id."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.QueuePlayIdentitySample,
|
||||
"queue_play_identity_sample",
|
||||
"Queue one audio sample by numeric sample id for local playback. This is asynchronous and does not block until playback finishes.")
|
||||
],
|
||||
Reasoning = options.EnableThinking
|
||||
? new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) }
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static LiteLlmResponsesCompactionOptions CreateCompactionOptions(
|
||||
AgentOptions options,
|
||||
IChatClient? summaryClient)
|
||||
{
|
||||
return new LiteLlmResponsesCompactionOptions
|
||||
{
|
||||
Enabled = options.EnableCompaction,
|
||||
ContextWindowTokens = options.ContextWindowTokens,
|
||||
MaxOutputTokens = options.MaxOutputTokens,
|
||||
RemainingRatio = options.CompactionRemainingRatio,
|
||||
CompactPath = options.ResponsesCompactPath,
|
||||
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
|
||||
options.ContextWindowTokens,
|
||||
options.MaxOutputTokens,
|
||||
options.CompactionRemainingRatio,
|
||||
summaryClient)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveApiKey(AgentOptions options, string agentName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Key))
|
||||
{
|
||||
return options.Key;
|
||||
}
|
||||
|
||||
var value = Environment.GetEnvironmentVariable(options.KeyEnv);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
|
||||
}
|
||||
|
||||
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => "none",
|
||||
ReasoningEffortOption.Low => "low",
|
||||
ReasoningEffortOption.High => "high",
|
||||
ReasoningEffortOption.ExtraHigh => "xhigh",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => ReasoningEffort.None,
|
||||
ReasoningEffortOption.Low => ReasoningEffort.Low,
|
||||
ReasoningEffortOption.High => ReasoningEffort.High,
|
||||
ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh,
|
||||
_ => ReasoningEffort.Medium
|
||||
};
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAI001
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class WorkflowRulesEditorChatViewModel
|
||||
{
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
|
||||
public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline)
|
||||
{
|
||||
this.pipeline = pipeline;
|
||||
}
|
||||
|
||||
public ObservableCollection<WorkflowRulesEditorChatMessage> Messages { get; } = [];
|
||||
|
||||
public string Draft { get; set; } = "";
|
||||
|
||||
public bool IsThinking { get; private set; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public async Task SendAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prompt = Draft.Trim();
|
||||
if (string.IsNullOrWhiteSpace(prompt) || IsThinking)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Draft = "";
|
||||
var priorConversation = Messages.ToList();
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
|
||||
IsThinking = true;
|
||||
OnChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await pipeline.SendAsync(
|
||||
priorConversation,
|
||||
prompt,
|
||||
cancellationToken);
|
||||
Messages.Clear();
|
||||
foreach (var message in result.Conversation)
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Rules editor failed: {exception.Message}"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsThinking = false;
|
||||
OnChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChanged()
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorInstructionBuilder
|
||||
{
|
||||
Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder
|
||||
{
|
||||
private const string DefaultPrompt = """
|
||||
You are the Meeting Assistant workflow rules and identities editor.
|
||||
|
||||
Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant.
|
||||
Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files.
|
||||
Read the existing rules before making changes unless the user explicitly asks to replace the whole file.
|
||||
Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal.
|
||||
When writing rules, prefer existing rule style and names.
|
||||
Use search_identities and read_identity before changing identities unless the user gives an exact identity id.
|
||||
Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response.
|
||||
For identity merges, merge the duplicate/source identity into the identity that should remain as target.
|
||||
Use list_identity_samples before playing, reading, or deleting samples. Use queue_play_identity_sample to let the user hear a sample; do not claim you heard it yourself.
|
||||
Use read_identity_sample only when the raw base64 WAV is actually useful; prefer list_identity_samples for ordinary inspection.
|
||||
Delete identities or samples only when the user clearly asked for deletion or cleanup.
|
||||
Explain the final change briefly after the tools finish.
|
||||
""";
|
||||
|
||||
private readonly ILogger<WorkflowRulesEditorInstructionBuilder> logger;
|
||||
|
||||
public WorkflowRulesEditorInstructionBuilder(ILogger<WorkflowRulesEditorInstructionBuilder> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var editorOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
var configuredPrompt = string.IsNullOrWhiteSpace(editorOptions.InitialPrompt)
|
||||
? DefaultPrompt
|
||||
: editorOptions.InitialPrompt!;
|
||||
var rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath) ?? "<not configured>";
|
||||
var docs = await ReadWorkflowDocsAsync(cancellationToken);
|
||||
|
||||
return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine +
|
||||
$"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine +
|
||||
"Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine +
|
||||
"Workflow rules reference documentation:" + Environment.NewLine +
|
||||
"```markdown" + Environment.NewLine +
|
||||
docs.Trim() + Environment.NewLine +
|
||||
"```";
|
||||
}
|
||||
|
||||
private async Task<string> ReadWorkflowDocsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var path in CandidateDocumentationPaths())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return await File.ReadAllTextAsync(path, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogWarning("Workflow rules editor could not find docs/meeting-workflow-engine.md for its prompt");
|
||||
return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime.";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> CandidateDocumentationPaths()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md");
|
||||
|
||||
var current = new DirectoryInfo(baseDirectory);
|
||||
for (var i = 0; i < 8 && current is not null; i++)
|
||||
{
|
||||
yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md");
|
||||
current = current.Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal enum WorkflowRulesEditorMarkdownInlineStyle
|
||||
{
|
||||
Normal,
|
||||
Italic,
|
||||
Bold,
|
||||
Code
|
||||
}
|
||||
|
||||
internal abstract record WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownParagraph(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTable(
|
||||
IReadOnlyList<string> Header,
|
||||
IReadOnlyList<IReadOnlyList<string>> Rows) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownInline(
|
||||
string Text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle Style);
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdown
|
||||
{
|
||||
public static IReadOnlyList<WorkflowRulesEditorMarkdownBlock> Parse(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var blocks = new List<WorkflowRulesEditorMarkdownBlock>();
|
||||
var paragraph = new List<string>();
|
||||
var code = new List<string>();
|
||||
var inCodeBlock = false;
|
||||
var lines = ReadLines(text).ToArray();
|
||||
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var line = lines[index];
|
||||
if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
if (inCodeBlock)
|
||||
{
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
|
||||
code.Clear();
|
||||
inCodeBlock = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
inCodeBlock = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock)
|
||||
{
|
||||
code.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsTableStart(lines, index))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
var header = ParseTableRow(lines[index]);
|
||||
index += 2;
|
||||
var rows = new List<IReadOnlyList<string>>();
|
||||
while (index < lines.Length && IsTableRow(lines[index]))
|
||||
{
|
||||
rows.Add(ParseTableRow(lines[index]));
|
||||
index++;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows));
|
||||
index--;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
}
|
||||
else
|
||||
{
|
||||
paragraph.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock)
|
||||
{
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
|
||||
}
|
||||
|
||||
FlushParagraph(blocks, paragraph);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowRulesEditorMarkdownInline> ParseInline(string text)
|
||||
{
|
||||
var result = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
var index = 0;
|
||||
while (index < text.Length)
|
||||
{
|
||||
var next = FindNextMarker(text, index);
|
||||
if (next < 0)
|
||||
{
|
||||
AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
break;
|
||||
}
|
||||
|
||||
if (next > index)
|
||||
{
|
||||
AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
}
|
||||
|
||||
if (text[next] == '`')
|
||||
{
|
||||
var end = text.IndexOf('`', next + 1);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
|
||||
{
|
||||
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
||||
index = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text[next] == '*')
|
||||
{
|
||||
var end = text.IndexOf('*', next + 1);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
index = next + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void FlushParagraph(
|
||||
List<WorkflowRulesEditorMarkdownBlock> blocks,
|
||||
List<string> paragraph)
|
||||
{
|
||||
if (paragraph.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph))));
|
||||
paragraph.Clear();
|
||||
}
|
||||
|
||||
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
|
||||
{
|
||||
return index + 1 < lines.Count &&
|
||||
IsTableRow(lines[index]) &&
|
||||
IsTableSeparator(lines[index + 1]);
|
||||
}
|
||||
|
||||
private static bool IsTableRow(string line)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsTableSeparator(string line)
|
||||
{
|
||||
var cells = ParseTableRow(line);
|
||||
return cells.Count > 0 &&
|
||||
cells.All(cell =>
|
||||
{
|
||||
var trimmed = cell.Trim();
|
||||
return trimmed.Contains('-', StringComparison.Ordinal) &&
|
||||
trimmed.All(character => character is '-' or ':' or ' ');
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseTableRow(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith('|'))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
|
||||
if (trimmed.EndsWith('|'))
|
||||
{
|
||||
trimmed = trimmed[..^1];
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split('|')
|
||||
.Select(cell => cell.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void AddInline(
|
||||
List<WorkflowRulesEditorMarkdownInline> result,
|
||||
string text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle style)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count > 0 && result[^1].Style == style)
|
||||
{
|
||||
result[^1] = result[^1] with { Text = result[^1].Text + text };
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
|
||||
}
|
||||
|
||||
private static int FindNextMarker(string text, int start)
|
||||
{
|
||||
var code = text.IndexOf('`', start);
|
||||
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
|
||||
var italic = text.IndexOf('*', start);
|
||||
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
|
||||
{
|
||||
italic = text.IndexOf('*', italic + 2);
|
||||
}
|
||||
|
||||
return new[] { code, bold, italic }
|
||||
.Where(index => index >= 0)
|
||||
.DefaultIfEmpty(-1)
|
||||
.Min();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadLines(string text)
|
||||
{
|
||||
using var reader = new StringReader(text);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#if WINDOWS
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
{
|
||||
public static UIElement CreateContent(string content)
|
||||
{
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(7);
|
||||
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
|
||||
{
|
||||
panel.Add(block switch
|
||||
{
|
||||
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
|
||||
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
|
||||
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
|
||||
_ => new TextBlock()
|
||||
});
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
|
||||
{
|
||||
var lines = SplitInlineLines(paragraph.Inlines);
|
||||
if (lines.Count == 1)
|
||||
{
|
||||
return CreateParagraphLine(lines[0]);
|
||||
}
|
||||
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(3);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
panel.Add(CreateParagraphLine(line));
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var linePanel = new WrapPanel()
|
||||
.Spacing(1);
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
foreach (var element in CreateInlineElements(inline))
|
||||
{
|
||||
linePanel.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return linePanel;
|
||||
}
|
||||
|
||||
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
|
||||
{
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
|
||||
{
|
||||
yield return CreateInlineCode(inline.Text);
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var token in SplitInlineText(inline.Text))
|
||||
{
|
||||
var text = new TextBlock()
|
||||
.Text(token)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243));
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
|
||||
{
|
||||
text.FontWeight(FontWeight.Bold);
|
||||
}
|
||||
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
|
||||
{
|
||||
text.FontFamily("Segoe UI Italic");
|
||||
}
|
||||
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
|
||||
var current = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
var parts = inline.Text.Split('\n');
|
||||
for (var index = 0; index < parts.Length; index++)
|
||||
{
|
||||
if (parts[index].Length > 0)
|
||||
{
|
||||
current.Add(inline with { Text = parts[index] });
|
||||
}
|
||||
|
||||
if (index < parts.Length - 1)
|
||||
{
|
||||
lines.Add(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static UIElement CreateInlineCode(string text)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(4, 1, 4, 2)
|
||||
.Margin(1, 0, 1, 0)
|
||||
.CornerRadius(4)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(24, 28, 35))
|
||||
.Child(new TextBlock()
|
||||
.Text(text)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
}
|
||||
|
||||
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
|
||||
{
|
||||
var rows = table.Rows
|
||||
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
|
||||
.ToArray();
|
||||
var gridView = new GridView()
|
||||
.HeaderHeight(30)
|
||||
.RowHeight(30)
|
||||
.CellPadding(new Thickness(8, 4, 8, 4))
|
||||
.ShowGridLines(true)
|
||||
.ZebraStriping(true);
|
||||
|
||||
for (var index = 0; index < table.Header.Count; index++)
|
||||
{
|
||||
var columnIndex = index;
|
||||
gridView.AddColumn<MarkdownTableRow>(
|
||||
table.Header[index],
|
||||
GetMarkdownTableColumnWidth(table, index),
|
||||
_ => new TextBlock()
|
||||
.TextWrapping(TextWrapping.NoWrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243)),
|
||||
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
|
||||
minWidth: 64,
|
||||
resizable: true);
|
||||
}
|
||||
|
||||
return gridView
|
||||
.ItemsSource(rows)
|
||||
.MinWidth(Math.Min(900, table.Header.Count * 120))
|
||||
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
|
||||
}
|
||||
|
||||
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(9)
|
||||
.CornerRadius(6)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(20, 24, 31))
|
||||
.Child(new TextBlock()
|
||||
.Text(codeBlock.Text)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitInlineText(string text)
|
||||
{
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
{
|
||||
var end = start;
|
||||
var isWhitespace = char.IsWhiteSpace(text[start]);
|
||||
while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace)
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
var token = text[start..end];
|
||||
if (token.Length > 0)
|
||||
{
|
||||
yield return token;
|
||||
}
|
||||
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
|
||||
{
|
||||
if (cells.Count >= count)
|
||||
{
|
||||
return cells;
|
||||
}
|
||||
|
||||
return cells
|
||||
.Concat(Enumerable.Repeat("", count - cells.Count))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double GetMarkdownTableColumnWidth(
|
||||
WorkflowRulesEditorMarkdownTable table,
|
||||
int index)
|
||||
{
|
||||
var maxLength = new[] { table.Header[index].Length }
|
||||
.Concat(table.Rows.Select(row => index < row.Count ? row[index].Length : 0))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return Math.Clamp(maxLength * 8 + 28, 72, 260);
|
||||
}
|
||||
|
||||
private sealed record MarkdownTableRow(IReadOnlyList<string> Cells)
|
||||
{
|
||||
public string GetCell(int index)
|
||||
{
|
||||
return index >= 0 && index < Cells.Count
|
||||
? Cells[index]
|
||||
: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Channels;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorSamplePlaybackQueue
|
||||
{
|
||||
Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue, IDisposable
|
||||
{
|
||||
private readonly Channel<QueuedSample> channel = Channel.CreateUnbounded<QueuedSample>();
|
||||
private readonly ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger;
|
||||
private readonly CancellationTokenSource cancellation = new();
|
||||
private readonly Task playbackTask;
|
||||
|
||||
public WorkflowRulesEditorSamplePlaybackQueue(ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
playbackTask = Task.Run(PlayQueuedSamplesAsync);
|
||||
}
|
||||
|
||||
public async Task<string> QueueAsync(
|
||||
int sampleId,
|
||||
byte[] wavBytes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (wavBytes.Length == 0)
|
||||
{
|
||||
return $"Sample {sampleId} is empty and was not queued.";
|
||||
}
|
||||
|
||||
await channel.Writer.WriteAsync(new QueuedSample(sampleId, wavBytes), cancellationToken);
|
||||
return $"Queued sample {sampleId} for playback.";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation.Cancel();
|
||||
channel.Writer.TryComplete();
|
||||
try
|
||||
{
|
||||
playbackTask.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task PlayQueuedSamplesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var sample in channel.Reader.ReadAllAsync(cancellation.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
await PlayAsync(sample, cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not play queued speaker identity sample {SampleId}",
|
||||
sample.SampleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PlayAsync(QueuedSample sample, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new MemoryStream(sample.WavBytes, writable: false);
|
||||
using var reader = new WaveFileReader(stream);
|
||||
using var output = new WaveOutEvent();
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
output.PlaybackStopped += (_, _) => completed.TrySetResult();
|
||||
output.Init(reader);
|
||||
output.Play();
|
||||
|
||||
await using var registration = cancellationToken.Register(() =>
|
||||
{
|
||||
output.Stop();
|
||||
completed.TrySetCanceled(cancellationToken);
|
||||
});
|
||||
await completed.Task;
|
||||
}
|
||||
|
||||
private sealed record QueuedSample(int SampleId, byte[] WavBytes);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorScrollPolicy
|
||||
{
|
||||
private const double BottomTolerance = 8;
|
||||
|
||||
public static bool ShouldAutoScroll(
|
||||
double verticalOffset,
|
||||
double viewportHeight,
|
||||
double previousContentHeight)
|
||||
{
|
||||
if (viewportHeight <= 0 || previousContentHeight <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previousContentHeight <= viewportHeight)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return verticalOffset + viewportHeight >= previousContentHeight - BottomTolerance;
|
||||
}
|
||||
|
||||
public static double GetBottomOffset(
|
||||
double viewportHeight,
|
||||
double contentHeight)
|
||||
{
|
||||
return Math.Max(0, contentHeight - viewportHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class WorkflowRulesEditorTools
|
||||
{
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private readonly string? rulesPath;
|
||||
private readonly SpeakerIdentificationOptions speakerOptions;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
|
||||
|
||||
public WorkflowRulesEditorTools(
|
||||
MeetingAssistantOptions options,
|
||||
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
|
||||
{
|
||||
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
speakerOptions = options.SpeakerIdentification;
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
}
|
||||
|
||||
public async Task<string> ReadRules(int? from = null, int? to = null)
|
||||
{
|
||||
if (rulesPath is null)
|
||||
{
|
||||
return "Workflow rules file is not configured.";
|
||||
}
|
||||
|
||||
if (!File.Exists(rulesPath))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to);
|
||||
}
|
||||
|
||||
public async Task<string> WriteRules(string yaml)
|
||||
{
|
||||
if (rulesPath is null)
|
||||
{
|
||||
return "Refused: workflow rules file is not configured.";
|
||||
}
|
||||
|
||||
if (yaml is null)
|
||||
{
|
||||
return "Refused: yaml must not be null.";
|
||||
}
|
||||
|
||||
var validation = ValidateYaml(yaml);
|
||||
if (validation is not null)
|
||||
{
|
||||
return validation;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
|
||||
await File.WriteAllTextAsync(rulesPath, yaml);
|
||||
return rulesPath;
|
||||
}
|
||||
|
||||
public async Task<string> Search(string keywords)
|
||||
{
|
||||
if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var rgResult = await RunRipgrepAsync(rulesPath, keywords);
|
||||
return rgResult is not null
|
||||
? FormatRipgrepJson(rgResult)
|
||||
: SearchWithRegexFallback(rulesPath, keywords);
|
||||
}
|
||||
|
||||
public async Task<string> SearchIdentities(string? query = null, int limit = 25)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var identities = await LoadIdentities(context)
|
||||
.OrderBy(identity => identity.CanonicalName ?? "")
|
||||
.ThenBy(identity => identity.Id)
|
||||
.ToListAsync();
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
var needle = query.Trim();
|
||||
identities = identities
|
||||
.Where(identity => IdentityMatches(identity, needle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return ToJson(identities
|
||||
.Take(Math.Clamp(limit, 1, 100))
|
||||
.Select(ToIdentitySummary)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ReadIdentity(int identityId)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var identity = await LoadIdentities(context)
|
||||
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
||||
return identity is null
|
||||
? $"Identity {identityId} was not found."
|
||||
: ToJson(ToIdentityDetail(identity));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> CreateIdentity(
|
||||
string? canonicalName = null,
|
||||
string[]? aliases = null,
|
||||
string[]? candidateNames = null)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = NormalizeNullable(canonicalName),
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Aliases = NormalizeNames(aliases)
|
||||
.Select(name => new SpeakerAlias { Name = name })
|
||||
.ToList(),
|
||||
CandidateNames = NormalizeNames(candidateNames)
|
||||
.Select(name => new SpeakerCandidateName { Name = name })
|
||||
.ToList()
|
||||
};
|
||||
context.SpeakerIdentities.Add(identity);
|
||||
await context.SaveChangesAsync();
|
||||
return ToJson(ToIdentityDetail(identity));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> UpdateIdentity(
|
||||
int identityId,
|
||||
string? canonicalName = null,
|
||||
string[]? aliases = null,
|
||||
string[]? candidateNames = null)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var identity = await LoadIdentities(context)
|
||||
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
||||
if (identity is null)
|
||||
{
|
||||
return $"Identity {identityId} was not found.";
|
||||
}
|
||||
|
||||
identity.CanonicalName = NormalizeNullable(canonicalName);
|
||||
identity.Aliases.Clear();
|
||||
identity.Aliases.AddRange(NormalizeNames(aliases)
|
||||
.Select(name => new SpeakerAlias { Name = name }));
|
||||
identity.CandidateNames.Clear();
|
||||
identity.CandidateNames.AddRange(NormalizeNames(candidateNames)
|
||||
.Select(name => new SpeakerCandidateName { Name = name }));
|
||||
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await context.SaveChangesAsync();
|
||||
return ToJson(ToIdentityDetail(identity));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DeleteIdentity(int identityId)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var identity = await LoadIdentities(context)
|
||||
.SingleOrDefaultAsync(identity => identity.Id == identityId);
|
||||
if (identity is null)
|
||||
{
|
||||
return $"Identity {identityId} was not found.";
|
||||
}
|
||||
|
||||
context.SpeakerIdentities.Remove(identity);
|
||||
await context.SaveChangesAsync();
|
||||
return $"Deleted identity {identityId}.";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> MergeIdentities(int targetIdentityId, int sourceIdentityId)
|
||||
{
|
||||
if (targetIdentityId == sourceIdentityId)
|
||||
{
|
||||
return "Refused: target and source identity are the same.";
|
||||
}
|
||||
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId);
|
||||
var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId);
|
||||
if (target is null || source is null)
|
||||
{
|
||||
return $"Could not find target {targetIdentityId} or source {sourceIdentityId}.";
|
||||
}
|
||||
|
||||
SpeakerIdentityMerger.MergeInto(
|
||||
target,
|
||||
source,
|
||||
speakerOptions.MaxSnippetsPerSpeaker);
|
||||
context.SpeakerIdentities.Remove(source);
|
||||
await context.SaveChangesAsync();
|
||||
return ToJson(ToIdentityDetail(target));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ListIdentitySamples(int identityId)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var samples = await context.SpeakerSnippets
|
||||
.Where(sample => sample.SpeakerIdentityId == identityId)
|
||||
.ToListAsync();
|
||||
return ToJson(samples
|
||||
.OrderBy(sample => sample.CreatedAt)
|
||||
.Select(ToIdentitySampleSummary)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> ReadIdentitySample(int sampleId)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
||||
return sample is null
|
||||
? $"Sample {sampleId} was not found."
|
||||
: ToJson(new IdentitySampleDetail(
|
||||
sample.Id,
|
||||
sample.SpeakerIdentityId,
|
||||
sample.CreatedAt,
|
||||
sample.WavBytes.Length,
|
||||
Convert.ToBase64String(sample.WavBytes)));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> DeleteIdentitySample(int sampleId)
|
||||
{
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
||||
if (sample is null)
|
||||
{
|
||||
return $"Sample {sampleId} was not found.";
|
||||
}
|
||||
|
||||
context.SpeakerSnippets.Remove(sample);
|
||||
await context.SaveChangesAsync();
|
||||
return $"Deleted sample {sampleId}.";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> QueuePlayIdentitySample(int sampleId)
|
||||
{
|
||||
if (samplePlaybackQueue is null)
|
||||
{
|
||||
return "Sample playback queue is not configured.";
|
||||
}
|
||||
|
||||
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
|
||||
if (context is null)
|
||||
{
|
||||
return "Speaker identity database is not configured.";
|
||||
}
|
||||
|
||||
await using (context)
|
||||
{
|
||||
var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId);
|
||||
return sample is null
|
||||
? $"Sample {sampleId} was not found."
|
||||
: await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidateYaml(string yaml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(yaml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = YamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml);
|
||||
return null;
|
||||
}
|
||||
catch (YamlException exception)
|
||||
{
|
||||
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> RunRipgrepAsync(string rulesPath, string keywords)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "rg",
|
||||
WorkingDirectory = Path.GetDirectoryName(rulesPath)!,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
startInfo.ArgumentList.Add("--json");
|
||||
startInfo.ArgumentList.Add("--line-number");
|
||||
startInfo.ArgumentList.Add("--color");
|
||||
startInfo.ArgumentList.Add("never");
|
||||
startInfo.ArgumentList.Add("--");
|
||||
startInfo.ArgumentList.Add(keywords);
|
||||
startInfo.ArgumentList.Add(Path.GetFileName(rulesPath));
|
||||
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var output = await process.StandardOutput.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
return process.ExitCode is 0 or 1 ? output : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatRipgrepJson(string output)
|
||||
{
|
||||
var matches = new List<string>();
|
||||
using var reader = new StringReader(output);
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
using var document = JsonDocument.Parse(line);
|
||||
var root = document.RootElement;
|
||||
if (!root.TryGetProperty("type", out var type) ||
|
||||
type.GetString() != "match" ||
|
||||
!root.TryGetProperty("data", out var data))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
|
||||
var lineNumber = data.GetProperty("line_number").GetInt32();
|
||||
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
|
||||
matches.Add($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
|
||||
}
|
||||
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
|
||||
private static string SearchWithRegexFallback(string rulesPath, string keywords)
|
||||
{
|
||||
try
|
||||
{
|
||||
var regex = new Regex(keywords, RegexOptions.IgnoreCase);
|
||||
var matches = new List<string>();
|
||||
var lines = File.ReadAllLines(rulesPath);
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
if (regex.IsMatch(lines[index]))
|
||||
{
|
||||
matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}");
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadLines(string content, int? from = null, int? to = null)
|
||||
{
|
||||
if (!from.HasValue && !to.HasValue)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
var lines = content
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.ToList();
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
|
||||
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
|
||||
if (end < start)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
return string.Join('\n', lines.GetRange(start, end - start + 1));
|
||||
}
|
||||
|
||||
private static string ToToolPath(string path)
|
||||
{
|
||||
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
|
||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async Task<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return dbContextFactory is null
|
||||
? null
|
||||
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await CreateIdentityContextAsync(cancellationToken);
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static IQueryable<SpeakerIdentity> LoadIdentities(SpeakerIdentityDbContext context)
|
||||
{
|
||||
return context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References);
|
||||
}
|
||||
|
||||
private static bool IdentityMatches(SpeakerIdentity identity, string query)
|
||||
{
|
||||
return new[] { identity.CanonicalName }
|
||||
.Concat(identity.Aliases.Select(alias => alias.Name))
|
||||
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity)
|
||||
{
|
||||
return new IdentitySummary(
|
||||
identity.Id,
|
||||
identity.CanonicalName,
|
||||
identity.GetDisplayName(),
|
||||
identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(),
|
||||
identity.Snippets.Count,
|
||||
identity.References.Count,
|
||||
identity.UpdatedAt);
|
||||
}
|
||||
|
||||
private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity)
|
||||
{
|
||||
return new IdentityDetail(
|
||||
ToIdentitySummary(identity),
|
||||
identity.References
|
||||
.OrderByDescending(reference => reference.CreatedAt)
|
||||
.Select(reference => new IdentityReferenceDetail(
|
||||
reference.Id,
|
||||
reference.MeetingNotePath,
|
||||
reference.TranscriptPath,
|
||||
reference.CreatedAt))
|
||||
.ToArray(),
|
||||
identity.Snippets
|
||||
.OrderBy(sample => sample.CreatedAt)
|
||||
.Select(ToIdentitySampleSummary)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample)
|
||||
{
|
||||
return new IdentitySampleSummary(
|
||||
sample.Id,
|
||||
sample.SpeakerIdentityId,
|
||||
sample.CreatedAt,
|
||||
sample.WavBytes.Length);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeNames(IEnumerable<string>? names)
|
||||
{
|
||||
return names?
|
||||
.Select(name => name.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Order(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray()
|
||||
?? [];
|
||||
}
|
||||
|
||||
private static string? NormalizeNullable(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static string ToJson<T>(T value)
|
||||
{
|
||||
return JsonSerializer.Serialize(value, JsonOptions);
|
||||
}
|
||||
|
||||
private sealed record IdentitySummary(
|
||||
int Id,
|
||||
string? CanonicalName,
|
||||
string? DisplayName,
|
||||
IReadOnlyList<string> Aliases,
|
||||
IReadOnlyList<string> CandidateNames,
|
||||
int SampleCount,
|
||||
int ReferenceCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
private sealed record IdentityDetail(
|
||||
IdentitySummary Identity,
|
||||
IReadOnlyList<IdentityReferenceDetail> References,
|
||||
IReadOnlyList<IdentitySampleSummary> Samples);
|
||||
|
||||
private sealed record IdentityReferenceDetail(
|
||||
int Id,
|
||||
string MeetingNotePath,
|
||||
string TranscriptPath,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
private sealed record IdentitySampleSummary(
|
||||
int Id,
|
||||
int IdentityId,
|
||||
DateTimeOffset CreatedAt,
|
||||
int ByteCount);
|
||||
|
||||
private sealed record IdentitySampleDetail(
|
||||
int Id,
|
||||
int IdentityId,
|
||||
DateTimeOffset CreatedAt,
|
||||
int ByteCount,
|
||||
string Base64Wav);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public static class WorkflowRulesPathResolver
|
||||
{
|
||||
public static string? Resolve(string? configuredPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"MeetingAssistant": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+M",
|
||||
"Abort": "Ctrl+Alt+D"
|
||||
"Abort": "Ctrl+Alt+Z"
|
||||
},
|
||||
"Vault": {
|
||||
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
|
||||
@@ -111,6 +111,11 @@
|
||||
"Automation": {
|
||||
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
||||
},
|
||||
"WorkflowRulesEditor": {
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "",
|
||||
"Model": ""
|
||||
},
|
||||
"Screenshots": {
|
||||
"Hotkey": "Ctrl+Alt+S",
|
||||
"AttachmentsFolder": "Attachments",
|
||||
@@ -123,9 +128,9 @@
|
||||
}
|
||||
},
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
"Endpoint": "http://127.0.0.1:4021",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "copilot-gpt-5.5",
|
||||
"Model": "chatgpt/gpt-5.5",
|
||||
"EnableThinking": true,
|
||||
"ReasoningEffort": "Medium",
|
||||
"ReconnectionAttempts": 2,
|
||||
|
||||
@@ -9,7 +9,9 @@ The application is intended to:
|
||||
- create an Obsidian markdown note before transcription starts
|
||||
- keep meeting metadata, user notes, detected context, and links to generated output in that note
|
||||
- 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
|
||||
- show a Windows taskbar notification icon with state-aware recording controls
|
||||
- capture active-window screenshots through a configurable global hotkey
|
||||
- capture microphone input and computer output into one transcription stream
|
||||
- transcribe meetings with speaker attribution
|
||||
@@ -87,7 +89,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"MeetingAssistant": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+M",
|
||||
"Abort": "Ctrl+Alt+D"
|
||||
"Abort": "Ctrl+Alt+Z"
|
||||
},
|
||||
"Vault": {
|
||||
"TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts",
|
||||
@@ -169,6 +171,11 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"Automation": {
|
||||
"RulesPath": "meeting-rules.local.yaml"
|
||||
},
|
||||
"WorkflowRulesEditor": {
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "",
|
||||
"Model": ""
|
||||
},
|
||||
"Screenshots": {
|
||||
"Hotkey": "Ctrl+Alt+S",
|
||||
"AttachmentsFolder": "Attachments",
|
||||
@@ -184,7 +191,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "copilot-gpt-5.5",
|
||||
"Model": "chatgpt/gpt-5.5",
|
||||
"EnableThinking": true,
|
||||
"ReasoningEffort": "Medium",
|
||||
"ReconnectionAttempts": 2,
|
||||
@@ -222,7 +229,11 @@ 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.
|
||||
|
||||
`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.
|
||||
|
||||
On Windows, Meeting Assistant shows a taskbar notification icon. The icon indicates idle, recording, or post-recording summarizing/processing state. Its right-click menu can start a recording for any configured launch profile, stop and transcribe the active recording, cancel and discard the active recording, or switch an active recording to another configured launch profile. A newer active recording takes priority in the icon state even if an older stopped run is still summarizing.
|
||||
|
||||
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 +251,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: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 writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks. 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.
|
||||
|
||||
@@ -269,6 +280,10 @@ When assistant context contains cropped screenshot links produced by OCR, the su
|
||||
- `write_projectfile`
|
||||
- `search`
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -22,6 +22,35 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
|
||||
|
||||
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
|
||||
|
||||
The tray menu includes `Edit rules`, which opens a small MewUI chat editor for this configured rules file. The editor uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
|
||||
|
||||
```json
|
||||
{
|
||||
"MeetingAssistant": {
|
||||
"WorkflowRulesEditor": {
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "",
|
||||
"Model": "",
|
||||
"EnableThinking": null,
|
||||
"ReasoningEffort": null,
|
||||
"MaxOutputTokens": null,
|
||||
"EnableCompaction": null,
|
||||
"CompactionRemainingRatio": null,
|
||||
"ResponsesCompactPath": "",
|
||||
"InitialPrompt": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`.
|
||||
|
||||
The editor agent prompt includes this document and is restricted to these tools:
|
||||
|
||||
- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range.
|
||||
- `write_rules`: overwrite the configured rules file after validating that the YAML parses as a workflow rules document.
|
||||
- `search`: search only the configured rules file with ripgrep-style syntax.
|
||||
|
||||
`POST /diagnostics/workflow/reload` reloads application configuration without restarting the process. Use it after changing workflow automation configuration such as `MeetingAssistant:Automation:RulesPath`. The endpoint returns the currently bound rules path:
|
||||
|
||||
```json
|
||||
@@ -302,9 +331,13 @@ Core files:
|
||||
- `MeetingAssistant/Workflow/MeetingWorkflowModels.cs`
|
||||
- `MeetingAssistant/Workflow/MeetingWorkflowEvent.cs`
|
||||
- `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs`
|
||||
- `MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs`
|
||||
- `MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs`
|
||||
- `MeetingAssistant/Workflow/MewUiWorkflowRulesEditorWindowService.Windows.cs`
|
||||
- `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowDiagnosticEndpointTests.cs`
|
||||
- `MeetingAssistant.Tests/WorkflowRulesEditorTests.cs`
|
||||
|
||||
When extending the workflow engine:
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
Meeting summaries are useful as full notes, but downstream views and file lists also need a very short human-readable blurb without parsing the full markdown body.
|
||||
|
||||
## What Changes
|
||||
- Require the summary agent to provide a one-line summary when calling `write_summary`.
|
||||
- Reject one-line summaries containing line breaks.
|
||||
- Store the one-line summary in summary note frontmatter.
|
||||
- Update default summary-agent instructions to keep the one-line summary very short and concise.
|
||||
|
||||
## Impact
|
||||
- Changes the `write_summary` tool signature.
|
||||
- Adds summary frontmatter field `oneliner`.
|
||||
- Adds focused tests for validation, frontmatter output, and instructions.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Summary note includes a one-line blurb
|
||||
The `write_summary` tool SHALL require a one-line summary parameter in addition to the markdown summary body.
|
||||
|
||||
Meeting Assistant SHALL reject `write_summary` calls when the one-line summary contains line breaks.
|
||||
|
||||
Meeting Assistant SHALL write the one-line summary into summary note frontmatter as `oneliner`.
|
||||
|
||||
The summary-agent instructions SHALL tell the agent to keep the one-line summary very short, blurb-like, and concise, with complete sentences not required.
|
||||
|
||||
#### Scenario: Summary one-liner is written to frontmatter
|
||||
- **WHEN** the summary agent calls `write_summary` with markdown content and one-line summary `Architecture risks and next steps`
|
||||
- **THEN** Meeting Assistant writes `oneliner: Architecture risks and next steps` to the summary note frontmatter
|
||||
|
||||
#### Scenario: Multiline one-liner is rejected
|
||||
- **WHEN** the summary agent calls `write_summary` with a one-line summary containing a line break
|
||||
- **THEN** Meeting Assistant refuses the write
|
||||
- **AND** does not mark the summary as written
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Specification
|
||||
- [x] Add summary one-liner requirements and scenarios.
|
||||
|
||||
## 2. Behavior
|
||||
- [x] Require `oneliner` in the `write_summary` tool signature.
|
||||
- [x] Validate `oneliner` does not contain line breaks.
|
||||
- [x] Write `oneliner` to summary note frontmatter.
|
||||
- [x] Instruct the summary agent to keep the one-liner very short.
|
||||
|
||||
## 3. Verification
|
||||
- [x] Add focused behavior tests.
|
||||
- [x] Run relevant tests.
|
||||
- [x] Validate OpenSpec change.
|
||||
@@ -0,0 +1,17 @@
|
||||
## Why
|
||||
Meeting Assistant currently exposes recording controls through hotkeys and HTTP endpoints, but there is no persistent Windows taskbar/tray affordance showing what the assistant is doing.
|
||||
|
||||
## What Changes
|
||||
- Add a Windows taskbar notification icon for Meeting Assistant.
|
||||
- Show idle, recording, or summarizing/processing state through the icon and tooltip.
|
||||
- Prioritize the newest active recording over older stopped runs that may still be summarizing.
|
||||
- Add a right-click menu with state-aware recording controls:
|
||||
- start meeting recording per configured launch profile
|
||||
- stop active recording and continue transcription/summary
|
||||
- cancel active recording and discard artifacts
|
||||
- switch the active recording to another configured launch profile
|
||||
|
||||
## Impact
|
||||
- Adds a small Windows-only hosted UI service.
|
||||
- Extends recording status with state/profile information needed by the tray surface.
|
||||
- Adds focused tests for tray state/menu behavior and launch profile enumeration.
|
||||
@@ -0,0 +1,36 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Windows taskbar icon controls recording
|
||||
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||
|
||||
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||
|
||||
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||
|
||||
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||
|
||||
#### Scenario: Idle tray menu can start configured profiles
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers start recording actions for `default` and `english`
|
||||
|
||||
#### Scenario: Recording tray menu exposes stop, cancel, and profile switches
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** a meeting is actively recording with profile `default`
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers stop and cancel actions
|
||||
- **AND** it offers switching to `english`
|
||||
- **AND** it does not offer switching to `default`
|
||||
|
||||
#### Scenario: Active recording has priority over older summarizing runs
|
||||
- **GIVEN** an older meeting is still summarizing
|
||||
- **WHEN** a newer meeting is actively recording
|
||||
- **THEN** the taskbar icon indicates recording
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Specification
|
||||
- [x] Add taskbar icon requirements and scenarios.
|
||||
|
||||
## 2. Behavior
|
||||
- [x] Expose enough recording status for idle/recording/summarizing and active profile display.
|
||||
- [x] Enumerate configured launch profiles for UI controls.
|
||||
- [x] Build state-aware tray menu model.
|
||||
- [x] Add Windows taskbar/tray hosted service with state icon and right-click commands.
|
||||
|
||||
## 3. Verification
|
||||
- [x] Add focused behavior tests for status/menu/profile behavior.
|
||||
- [x] Run relevant tests.
|
||||
- [x] Validate OpenSpec change.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Add workflow rules editor UI
|
||||
|
||||
## Why
|
||||
|
||||
Workflow rules are currently edited by opening the local YAML file manually. A small tray-launched assistant UI should make rule edits faster while keeping the agent constrained to the configured rules file and documented workflow format.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an `Edit rules` tray menu item.
|
||||
- Replace the tray icon implementation with the Uno notification icon stack.
|
||||
- Add a MewUI chat window for editing workflow rules.
|
||||
- Add a workflow-rules editor agent pipeline with read, write, and search tools scoped to the configured rules file.
|
||||
- Allow the editor agent model configuration to inherit summarizer settings by default and be overridden independently.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `meeting-session`
|
||||
- Affected code: taskbar host, workflow automation, agent client configuration, Windows UI package references
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
|
||||
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
|
||||
|
||||
The tray icon SHALL be implemented through the Uno notification icon stack.
|
||||
|
||||
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
|
||||
|
||||
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
|
||||
|
||||
The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
|
||||
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
|
||||
|
||||
The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon.
|
||||
|
||||
The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults.
|
||||
|
||||
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
|
||||
|
||||
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
|
||||
|
||||
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
|
||||
|
||||
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
|
||||
|
||||
Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu.
|
||||
|
||||
#### Scenario: Tray menu opens the editor
|
||||
- **WHEN** the user opens the tray icon menu
|
||||
- **THEN** the menu includes `Edit rules and identities`
|
||||
- **WHEN** the user selects `Edit rules and identities`
|
||||
- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window
|
||||
|
||||
#### Scenario: Tray menu shows configured profile hotkeys
|
||||
- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M`
|
||||
- **AND** the `english` launch profile uses `Ctrl+Alt+E`
|
||||
- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle
|
||||
- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M`
|
||||
- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E`
|
||||
|
||||
#### Scenario: Rules editor can be opened diagnostically
|
||||
- **WHEN** a diagnostic caller requests the workflow rules editor to open
|
||||
- **THEN** Meeting Assistant invokes the workflow rules editor window service
|
||||
|
||||
#### Scenario: Rules editor is scoped to the configured rules file
|
||||
- **GIVEN** a configured workflow rules file
|
||||
- **WHEN** the rules editor agent runs
|
||||
- **THEN** its read, write, and search tools can access only that configured workflow rules file
|
||||
- **AND** its system prompt includes the workflow engine documentation
|
||||
|
||||
#### Scenario: Rules editor can manage speaker identities
|
||||
- **GIVEN** the speaker identity database contains speaker identities and samples
|
||||
- **WHEN** the rules editor agent runs
|
||||
- **THEN** it can search, read, create, update, delete, and merge speaker identities
|
||||
- **AND** it can list, read, delete, and queue playback of identity samples
|
||||
|
||||
#### Scenario: User sends a rules-editing chat turn
|
||||
- **GIVEN** the rules editor chat window is open
|
||||
- **WHEN** the user types a prompt and presses Enter
|
||||
- **THEN** the user message appears in the conversation
|
||||
- **AND** a plain `Thinking...` line appears while the agent is running
|
||||
- **AND** the first model request for that turn is marked as user-initiated
|
||||
- **AND** the final assistant response replaces the thinking line
|
||||
|
||||
#### Scenario: Chat auto-scrolls after appended content
|
||||
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
|
||||
- **WHEN** a new user or assistant message is appended
|
||||
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
||||
@@ -0,0 +1,16 @@
|
||||
## 1. Requirements
|
||||
|
||||
- [x] Add meeting-session requirement scenarios for tray-launched rules and identities editing.
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] Add scoped workflow-rules and speaker-identity editor tools and prompt builder.
|
||||
- [x] Add workflow-rules editor chat pipeline with compaction and user-initiated requests.
|
||||
- [x] Add MewUI chat window.
|
||||
- [x] Add a Windows application/window icon for the MewUI editor taskbar button.
|
||||
- [x] Replace WinForms tray icon with Uno notification icon host and add `Edit rules and identities`.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] Add focused tests for options fallback, tool scoping, identity tools, prompt content, and view-model chat behavior.
|
||||
- [x] Run relevant tests and strict OpenSpec validation.
|
||||
@@ -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.
|
||||
+82
@@ -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
|
||||
+37
@@ -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.
|
||||
+34
@@ -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
|
||||
+11
@@ -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`.
|
||||
@@ -107,3 +107,73 @@ Meeting Assistant SHALL use the selected launch profile's summary-agent settings
|
||||
- **GIVEN** two launch profiles configure the same toggle hotkey
|
||||
- **WHEN** Meeting Assistant resolves launch profiles
|
||||
- **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
|
||||
|
||||
### Requirement: Windows taskbar icon controls recording
|
||||
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||
|
||||
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||
|
||||
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||
|
||||
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||
|
||||
#### Scenario: Idle tray menu can start configured profiles
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers start recording actions for `default` and `english`
|
||||
|
||||
#### Scenario: Recording tray menu exposes stop, cancel, and profile switches
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** a meeting is actively recording with profile `default`
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers stop and cancel actions
|
||||
- **AND** it offers switching to `english`
|
||||
- **AND** it does not offer switching to `default`
|
||||
|
||||
#### Scenario: Active recording has priority over older summarizing runs
|
||||
- **GIVEN** an older meeting is still summarizing
|
||||
- **WHEN** a newer meeting is actively recording
|
||||
- **THEN** the taskbar icon indicates recording
|
||||
|
||||
@@ -182,3 +182,73 @@ Meeting Assistant SHALL support these initial rule steps:
|
||||
- **GIVEN** a configured state-transition rule that matches a title containing a configured marker
|
||||
- **WHEN** the rule runs
|
||||
- **THEN** Meeting Assistant can update the meeting title through `set_property`
|
||||
|
||||
### Requirement: Workflow rules and speaker identities can be edited through a tray-launched assistant
|
||||
Meeting Assistant SHALL expose an `Edit rules and identities` item from the tray icon menu.
|
||||
|
||||
The tray icon SHALL be implemented through the Uno notification icon stack.
|
||||
|
||||
The tray icon menu SHALL show every configured launch profile when recording can be started and SHALL include each profile's configured toggle hotkey in the corresponding start or switch menu item.
|
||||
|
||||
When the user selects `Edit rules and identities`, Meeting Assistant SHALL open a basic MewUI chat window for editing the configured workflow rules file and speaker identities.
|
||||
|
||||
The chat window SHALL be titled `Edit rules and identities`, SHALL display user and assistant messages as visually distinct cards, SHALL display basic markdown emphasis, inline code, fenced code blocks, pipe tables rendered as grid views, and line breaks in agent responses, SHALL display a plain `Thinking...` line while the agent is working, SHALL provide a multiline text input at the bottom with placeholder text for asking to make a rule or list identities, SHALL send on Enter, SHALL insert a newline on Shift+Enter, and SHALL provide an explicit Send button.
|
||||
|
||||
When a new chat message or thinking state is appended, the chat window SHALL scroll to the bottom of the newly rendered content if the conversation was already near the bottom or did not need scrolling before the append.
|
||||
|
||||
The Windows executable and rules-and-identities editor window SHALL use the Meeting Assistant application icon so the editor has a taskbar icon.
|
||||
|
||||
The rules editor agent SHALL be configured from the summarizer agent settings by default, while allowing workflow-rules-editor-specific endpoint, key, model, reasoning, reconnection, output, and compaction settings to override those defaults.
|
||||
|
||||
The rules and identities editor agent SHALL include the workflow engine documentation in its system prompt and SHALL receive read, write, and search tools scoped to the configured workflow rules file.
|
||||
|
||||
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
|
||||
|
||||
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
|
||||
|
||||
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
|
||||
|
||||
Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow rules editor through the same window service used by the tray menu.
|
||||
|
||||
#### Scenario: Tray menu opens the editor
|
||||
- **WHEN** the user opens the tray icon menu
|
||||
- **THEN** the menu includes `Edit rules and identities`
|
||||
- **WHEN** the user selects `Edit rules and identities`
|
||||
- **THEN** Meeting Assistant opens the workflow rules and identities editor chat window
|
||||
|
||||
#### Scenario: Tray menu shows configured profile hotkeys
|
||||
- **GIVEN** the `default` launch profile uses `Ctrl+Alt+M`
|
||||
- **AND** the `english` launch profile uses `Ctrl+Alt+E`
|
||||
- **WHEN** the user opens the tray icon menu while Meeting Assistant is idle
|
||||
- **THEN** the menu includes a start item for `default` showing `Ctrl+Alt+M`
|
||||
- **AND** the menu includes a start item for `english` showing `Ctrl+Alt+E`
|
||||
|
||||
#### Scenario: Rules editor can be opened diagnostically
|
||||
- **WHEN** a diagnostic caller requests the workflow rules editor to open
|
||||
- **THEN** Meeting Assistant invokes the workflow rules editor window service
|
||||
|
||||
#### Scenario: Rules editor is scoped to the configured rules file
|
||||
- **GIVEN** a configured workflow rules file
|
||||
- **WHEN** the rules editor agent runs
|
||||
- **THEN** its read, write, and search tools can access only that configured workflow rules file
|
||||
- **AND** its system prompt includes the workflow engine documentation
|
||||
|
||||
#### Scenario: Rules editor can manage speaker identities
|
||||
- **GIVEN** the speaker identity database contains speaker identities and samples
|
||||
- **WHEN** the rules editor agent runs
|
||||
- **THEN** it can search, read, create, update, delete, and merge speaker identities
|
||||
- **AND** it can list, read, delete, and queue playback of identity samples
|
||||
|
||||
#### Scenario: User sends a rules-editing chat turn
|
||||
- **GIVEN** the rules editor chat window is open
|
||||
- **WHEN** the user types a prompt and presses Enter
|
||||
- **THEN** the user message appears in the conversation
|
||||
- **AND** a plain `Thinking...` line appears while the agent is running
|
||||
- **AND** the first model request for that turn is marked as user-initiated
|
||||
- **AND** the final assistant response replaces the thinking line
|
||||
|
||||
#### Scenario: Chat auto-scrolls after appended content
|
||||
- **GIVEN** the rules editor chat window content fits without scrolling or is already near the bottom
|
||||
- **WHEN** a new user or assistant message is appended
|
||||
- **THEN** the conversation scrolls to the bottom of the newly rendered message content
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change define-meeting-assistant-v1. Update Purpose after archive.
|
||||
|
||||
## 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.
|
||||
@@ -33,6 +32,10 @@ The summary pipeline SHALL expose tools scoped to the current meeting:
|
||||
- `read_usernotes`
|
||||
- `read_glossary`
|
||||
- `add_dictation_word`
|
||||
- `add_attendee`
|
||||
- `remove_attendee`
|
||||
- `override_speaker`
|
||||
- `delete_identity`
|
||||
- `write_summary`
|
||||
- `write_context`
|
||||
- `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 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
|
||||
- **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
|
||||
@@ -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
|
||||
- **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
|
||||
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.
|
||||
|
||||
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
|
||||
- **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
|
||||
- **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
|
||||
Meeting Assistant SHALL allow the summary agent initial prompt to be configured through application settings.
|
||||
|
||||
@@ -201,3 +258,21 @@ When no bound projects have `AGENTS.md`, Meeting Assistant SHALL not append the
|
||||
- **AND** the configured project folder does not contain `AGENTS.md`
|
||||
- **WHEN** the summary agent is created
|
||||
- **THEN** no empty project instruction entry is appended for `Alpha`
|
||||
|
||||
### Requirement: Summary note includes a one-line blurb
|
||||
The `write_summary` tool SHALL require a one-line summary parameter in addition to the markdown summary body.
|
||||
|
||||
Meeting Assistant SHALL reject `write_summary` calls when the one-line summary contains line breaks.
|
||||
|
||||
Meeting Assistant SHALL write the one-line summary into summary note frontmatter as `oneliner`.
|
||||
|
||||
The summary-agent instructions SHALL tell the agent to keep the one-line summary very short, blurb-like, and concise, with complete sentences not required.
|
||||
|
||||
#### Scenario: Summary one-liner is written to frontmatter
|
||||
- **WHEN** the summary agent calls `write_summary` with markdown content and one-line summary `Architecture risks and next steps`
|
||||
- **THEN** Meeting Assistant writes `oneliner: Architecture risks and next steps` to the summary note frontmatter
|
||||
|
||||
#### Scenario: Multiline one-liner is rejected
|
||||
- **WHEN** the summary agent calls `write_summary` with a one-line summary containing a line break
|
||||
- **THEN** Meeting Assistant refuses the write
|
||||
- **AND** does not mark the summary as written
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
- **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
|
||||
@@ -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
|
||||
- **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
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -391,3 +425,13 @@ When the Azure live streaming backend uses `ConversationTranscriber`, Meeting As
|
||||
- **WHEN** `AzureSpeech.PostProcessingOption` is empty
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user