Public Access
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aae627095 | ||
|
|
c12febe029 | ||
|
|
4f0b52d7d0 | ||
|
|
12832dde84 | ||
|
|
2422236ef7 | ||
|
|
b114071957 | ||
|
|
e85274829a |
@@ -48,6 +48,8 @@ MeetingAssistant/appsettings.Local.json
|
||||
MeetingAssistant/appsettings.*.local.json
|
||||
MeetingAssistant/**/appsettings.Local.json
|
||||
MeetingAssistant/**/appsettings.*.local.json
|
||||
meeting-rules.local.yaml
|
||||
MeetingAssistant/meeting-rules.local.yaml
|
||||
.meeting-assistant/
|
||||
tmp/
|
||||
recordings/
|
||||
|
||||
@@ -71,6 +71,8 @@ For Meeting Assistant, prefer verification through:
|
||||
- transcription and summarization behavior tests
|
||||
- application logs proving the end-to-end flow
|
||||
|
||||
Before restarting the local Meeting Assistant application, check whether a meeting is currently being recorded, transcribed, finalized, or summarized through the recording status endpoint and/or logs. Do not restart the application while an active meeting run is recording, transcribing, finalizing speaker recognition, waiting on OCR, or summarizing unless the user explicitly tells you to restart anyway.
|
||||
|
||||
If production or end-to-end verification is not possible, state exactly why and what lower-level verification was performed instead.
|
||||
|
||||
## Implementation Notes
|
||||
@@ -80,6 +82,18 @@ Keep source and deployment ownership separate:
|
||||
- `Manuel/meeting-assistant` owns application source, tests, build configuration, container image publishing, and OpenSpec source changes.
|
||||
- A future deployment repository, if created, should own Docker Compose, Traefik labels, secrets/variables, networks, and live image tag selection.
|
||||
|
||||
## Workflow Engine Documentation
|
||||
|
||||
When changing the meeting workflow engine, update `docs/meeting-workflow-engine.md` in the same change. This includes changes to:
|
||||
|
||||
- YAML rule shape or configuration
|
||||
- supported triggers, states, conditions, expression variables, helper functions, or Razor template model
|
||||
- supported steps or step semantics
|
||||
- workflow event emission points in the recording lifecycle
|
||||
- safety behavior for missing files, invalid rules, persistence, or local ignored rule files
|
||||
|
||||
Keep `README.md` as the short operational overview and keep `docs/meeting-workflow-engine.md` as the detailed reference.
|
||||
|
||||
Before finishing code changes, run the narrowest useful test command first. For broader changes, run:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -30,6 +30,37 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
Assert.StartsWith("endpoint-bytes:", segment.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NamedProfileEndpointPassesProfileToConfiguredProvider()
|
||||
{
|
||||
var pipelineFactory = new CapturingProfileSpeechRecognitionPipelineFactory();
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
});
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISpeechRecognitionPipelineFactory>();
|
||||
services.AddSingleton<ISpeechRecognitionPipelineFactory>(pipelineFactory);
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var wavPath = Path.Combine(AppContext.BaseDirectory, "Fixtures", "sample-16khz-mono.wav");
|
||||
|
||||
using var response = await client.PostAsJsonAsync("/profiles/english/asr/transcribe-file", new { path = wavPath });
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("english", pipelineFactory.LastProfileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EndpointReportsProviderFailuresAsBadGateway()
|
||||
{
|
||||
@@ -179,6 +210,24 @@ public sealed class AsrDiagnosticEndpointTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingProfileSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
public string? LastProfileName { get; private set; }
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
LastProfileName = launchProfileName;
|
||||
return new TestSpeechRecognitionPipeline(
|
||||
new EndpointFakeTranscriptionProvider(),
|
||||
(_, liveSegments, _, _) => Task.FromResult(liveSegments));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestSpeechRecognitionPipeline : StreamingSpeechRecognitionPipeline
|
||||
{
|
||||
private readonly Func<string, IReadOnlyList<TranscriptionSegment>, SpeechRecognitionPipelineOptions, CancellationToken, Task<IReadOnlyList<TranscriptionSegment>>> finalize;
|
||||
|
||||
@@ -100,7 +100,27 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(ISpeakerIdentityDiarizationClient diarizationClient)
|
||||
[Fact]
|
||||
public async Task MatcherReturnsNullWhenDiarizationTimesOut()
|
||||
{
|
||||
var matcher = CreateMatcher(
|
||||
new HangingSpeakerIdentityDiarizationClient(),
|
||||
TimeSpan.FromMilliseconds(20));
|
||||
var snippet = CreateWavSnippet();
|
||||
|
||||
var match = await matcher.MatchAsync(
|
||||
new SpeakerIdentityMatchRequest(
|
||||
"Guest03",
|
||||
snippet,
|
||||
[new SpeakerIdentityMatchCandidate(42, "Chris", 5, [snippet])]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(match);
|
||||
}
|
||||
|
||||
private static AzureSpeechSpeakerIdentityMatcher CreateMatcher(
|
||||
ISpeakerIdentityDiarizationClient diarizationClient,
|
||||
TimeSpan? matchTimeout = null)
|
||||
{
|
||||
return new AzureSpeechSpeakerIdentityMatcher(
|
||||
diarizationClient,
|
||||
@@ -108,7 +128,8 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
{
|
||||
SpeakerIdentification = new SpeakerIdentificationOptions
|
||||
{
|
||||
SilenceBetweenSnippetsSeconds = 1
|
||||
SilenceBetweenSnippetsSeconds = 1,
|
||||
MatchTimeout = matchTimeout ?? TimeSpan.FromMinutes(1)
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechSpeakerIdentityMatcher>.Instance);
|
||||
@@ -145,4 +166,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcherTests
|
||||
return Task.FromResult(segments);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HangingSpeakerIdentityDiarizationClient : ISpeakerIdentityDiarizationClient
|
||||
{
|
||||
public async Task<IReadOnlyList<TranscriptionSegment>> DiarizeAsync(
|
||||
string wavPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.CognitiveServices.Speech;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -62,6 +63,50 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSpeechConfigSkipsConfiguredPostProcessingOptionForConversationTranscriber()
|
||||
{
|
||||
var provider = new AzureSpeechStreamingTranscriptionProvider(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
AzureSpeech = new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "wss://westeurope.stt.speech.microsoft.com/speech/universal/v2",
|
||||
Language = "de-DE",
|
||||
Key = "test-key",
|
||||
PostProcessingOption = "PostRefinement"
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var speechConfig = provider.CreateSpeechConfig();
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(
|
||||
speechConfig.GetProperty(PropertyId.SpeechServiceResponse_PostProcessingOption)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSpeechConfigDoesNotApplyBlankPostProcessingOption()
|
||||
{
|
||||
var provider = new AzureSpeechStreamingTranscriptionProvider(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
AzureSpeech = new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "wss://westeurope.stt.speech.microsoft.com/speech/universal/v2",
|
||||
Language = "de-DE",
|
||||
Key = "test-key",
|
||||
PostProcessingOption = " "
|
||||
}
|
||||
}),
|
||||
NullLogger<AzureSpeechStreamingTranscriptionProvider>.Instance);
|
||||
|
||||
var speechConfig = provider.CreateSpeechConfig();
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(
|
||||
speechConfig.GetProperty(PropertyId.SpeechServiceResponse_PostProcessingOption)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveKeyFallsBackToUserEnvironment()
|
||||
{
|
||||
@@ -82,15 +127,17 @@ public sealed class AzureSpeechStreamingTranscriptionProviderTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EndpointDefaultsToUniversalV2SpeechEndpointForRegion()
|
||||
public void CreateSpeechConfigUsesSubscriptionWhenEndpointIsBlank()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Uri("wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2"),
|
||||
AzureSpeechStreamingTranscriptionProvider.GetEndpoint(new AzureSpeechOptions
|
||||
var speechConfig = AzureSpeechStreamingTranscriptionProvider.CreateSpeechConfig(
|
||||
new AzureSpeechOptions
|
||||
{
|
||||
Endpoint = "",
|
||||
Region = "germanywestcentral"
|
||||
}));
|
||||
Region = "westeurope"
|
||||
},
|
||||
"test-key");
|
||||
|
||||
Assert.NotNull(speechConfig);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -24,11 +27,101 @@ public sealed class ConfiguredSpeechRecognitionPipelineFactoryTests
|
||||
services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||
provider,
|
||||
[new AzureSpeechRecognitionPipelineBuilder(provider)],
|
||||
provider.GetRequiredService<IOptions<MeetingAssistantOptions>>());
|
||||
|
||||
await using var pipeline = factory.Create();
|
||||
|
||||
Assert.IsType<AzureSpeechRecognitionPipeline>(pipeline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FactoryPassesResolvedLaunchProfileOptionsToSelectedBuilder()
|
||||
{
|
||||
var services = new ServiceCollection().BuildServiceProvider();
|
||||
var builder = new CapturingPipelineBuilder();
|
||||
var launchProfiles = new ConfigurationLaunchProfileOptionsProvider(
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "capturing",
|
||||
["MeetingAssistant:Agent:Model"] = "default-model",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Recording:TranscriptionProvider"] = "capturing",
|
||||
["MeetingAssistant:LaunchProfiles:english:Agent:Model"] = "english-model"
|
||||
})
|
||||
.Build());
|
||||
var factory = new ConfiguredSpeechRecognitionPipelineFactory(
|
||||
[builder],
|
||||
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
|
||||
launchProfiles);
|
||||
|
||||
await using var pipeline = factory.Create("english");
|
||||
|
||||
Assert.IsType<CapturingPipeline>(pipeline);
|
||||
Assert.Equal("english-model", builder.Options?.Agent.Model);
|
||||
}
|
||||
|
||||
private sealed class CapturingPipelineBuilder : ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
public string ProviderName => "capturing";
|
||||
|
||||
public MeetingAssistantOptions? Options { get; private set; }
|
||||
|
||||
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||
{
|
||||
Options = options;
|
||||
return new CapturingPipeline();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingPipeline : ISpeechRecognitionPipeline
|
||||
{
|
||||
public Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask WriteAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<TranscriptionSegment> ReadLiveTranscriptAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TranscriptionSegment>> ReadFinishedTranscriptAsync(
|
||||
string audioPath,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<TranscriptionSegment>>([]);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LaunchProfileOptionsProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultProfileUsesRootMeetingAssistantConfiguration()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:AzureSpeech:Language"] = "de-DE"
|
||||
});
|
||||
|
||||
var profile = provider.GetRequiredProfile(null);
|
||||
|
||||
Assert.Equal("default", profile.Name);
|
||||
Assert.Equal("Ctrl+Alt+M", profile.Options.Hotkey.Toggle);
|
||||
Assert.Equal("azure-speech", profile.Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("de-DE", profile.Options.AzureSpeech.Language);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedProfileMergesOverrideOntoDefaultConfiguration()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Recording:TranscriptionProvider"] = "azure-speech",
|
||||
["MeetingAssistant:AzureSpeech:Region"] = "germanywestcentral",
|
||||
["MeetingAssistant:AzureSpeech:Language"] = "de-DE",
|
||||
["MeetingAssistant:AzureSpeech:AutoDetectLanguages:0"] = "de-DE",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:AutoDetectLanguages:0"] = "en-US"
|
||||
});
|
||||
|
||||
var profile = provider.GetRequiredProfile("english");
|
||||
|
||||
Assert.Equal("english", profile.Name);
|
||||
Assert.Equal("Ctrl+Alt+E", profile.Options.Hotkey.Toggle);
|
||||
Assert.Equal("azure-speech", profile.Options.Recording.TranscriptionProvider);
|
||||
Assert.Equal("germanywestcentral", profile.Options.AzureSpeech.Region);
|
||||
Assert.Equal("en-US", profile.Options.AzureSpeech.Language);
|
||||
Assert.Equal(["en-US"], profile.Options.AzureSpeech.AutoDetectLanguages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateProfileHotkeysAreRejected()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+M"
|
||||
});
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => provider.GetHotkeys());
|
||||
Assert.Contains("Ctrl+Alt+M", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScreenshotHotkeysAreRegisteredForDefaultAndExplicitProfileOverrides()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
|
||||
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Toggle"] = "Ctrl+Alt+E",
|
||||
["MeetingAssistant:LaunchProfiles:english:Hotkey:Abort"] = "Ctrl+Alt+Shift+D",
|
||||
["MeetingAssistant:LaunchProfiles:english:Screenshots:Hotkey"] = "Ctrl+Alt+Shift+S"
|
||||
});
|
||||
|
||||
var hotkeys = provider.GetHotkeys();
|
||||
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "default" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+S" &&
|
||||
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "english" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+Shift+S" &&
|
||||
hotkey.Action == LaunchProfileHotkeyAction.CaptureScreenshot);
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "default" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+D" &&
|
||||
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
|
||||
Assert.Contains(hotkeys, hotkey =>
|
||||
hotkey.ProfileName == "english" &&
|
||||
hotkey.Hotkey == "Ctrl+Alt+Shift+D" &&
|
||||
hotkey.Action == LaunchProfileHotkeyAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamedProfilesDoNotInheritDefaultHotkeysForRegistration()
|
||||
{
|
||||
var provider = CreateProvider(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:Hotkey:Toggle"] = "Ctrl+Alt+M",
|
||||
["MeetingAssistant:Hotkey:Abort"] = "Ctrl+Alt+D",
|
||||
["MeetingAssistant:Screenshots:Hotkey"] = "Ctrl+Alt+S",
|
||||
["MeetingAssistant:LaunchProfiles:english:AzureSpeech:Language"] = "en-US"
|
||||
});
|
||||
|
||||
var hotkeys = provider.GetHotkeys();
|
||||
|
||||
Assert.Equal(3, hotkeys.Count);
|
||||
Assert.DoesNotContain(hotkeys, hotkey => hotkey.ProfileName == "english");
|
||||
}
|
||||
|
||||
private static ConfigurationLaunchProfileOptionsProvider CreateProvider(
|
||||
IReadOnlyDictionary<string, string?> values)
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
return new ConfigurationLaunchProfileOptionsProvider(configuration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
#pragma warning disable CA1416
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [1, 2, 3]);
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{ "type": "output_text", "text": "Visible slide text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Endpoint = "https://summary.local",
|
||||
Model = "summary-model",
|
||||
Key = "agent-key"
|
||||
},
|
||||
Screenshots =
|
||||
{
|
||||
Ocr =
|
||||
{
|
||||
Endpoint = "",
|
||||
Model = "",
|
||||
Key = "ocr-key"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Visible slide text", result.Text);
|
||||
Assert.Null(result.Crop);
|
||||
Assert.Equal(new Uri("https://summary.local/v1/responses"), handler.RequestUri);
|
||||
Assert.Equal("Bearer", handler.Authorization?.Scheme);
|
||||
Assert.Equal("ocr-key", handler.Authorization?.Parameter);
|
||||
using var payload = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.Equal("summary-model", payload.RootElement.GetProperty("model").GetString());
|
||||
var content = payload.RootElement
|
||||
.GetProperty("input")[0]
|
||||
.GetProperty("content");
|
||||
Assert.Equal("Extract screenshot.", content[0].GetProperty("text").GetString());
|
||||
Assert.Equal("data:image/png;base64,AQID", content[1].GetProperty("image_url").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [4, 5, 6]);
|
||||
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Endpoint = "https://summary.local",
|
||||
Model = "summary-model",
|
||||
Key = "agent-key"
|
||||
},
|
||||
Screenshots =
|
||||
{
|
||||
Ocr =
|
||||
{
|
||||
Endpoint = "https://vision.local/v1",
|
||||
Model = "vision-model",
|
||||
Key = "ocr-key"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("OCR result", result.Text);
|
||||
Assert.Equal(new Uri("https://vision.local/v1/responses"), handler.RequestUri);
|
||||
using var payload = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.Equal("vision-model", payload.RootElement.GetProperty("model").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\n```"
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Slide text", result.Text);
|
||||
Assert.Equal(new ScreenshotCropCoordinates(1, 2, 3, 4), result.Crop);
|
||||
using var payload = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.Contains(
|
||||
"Original screenshot dimensions: 8x6 pixels.",
|
||||
payload.RootElement.GetProperty("input")[0].GetProperty("content")[0].GetProperty("text").GetString(),
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string responseBody;
|
||||
|
||||
public RecordingHandler(string responseBody)
|
||||
{
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public Uri? RequestUri { get; private set; }
|
||||
|
||||
public AuthenticationHeaderValue? Authorization { get; private set; }
|
||||
|
||||
public string? RequestBody { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RequestUri = request.RequestUri;
|
||||
Authorization = request.Headers.Authorization;
|
||||
RequestBody = request.Content is null
|
||||
? null
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreatePngBytes(int width, int height)
|
||||
{
|
||||
using var bitmap = new Bitmap(width, height);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.White);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
@@ -16,9 +16,13 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
"Review previous decisions\nAgree next steps",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
@@ -26,7 +30,9 @@ public sealed class MeetingArtifactStoreTests
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("# Assistant Context", content);
|
||||
Assert.StartsWith("---", content, StringComparison.Ordinal);
|
||||
Assert.Contains("state: transcribing", content);
|
||||
Assert.Contains("title: Planning Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("state: collecting metadata", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Review previous decisions", content);
|
||||
Assert.Contains(" Agree next steps", content);
|
||||
@@ -34,6 +40,7 @@ public sealed class MeetingArtifactStoreTests
|
||||
Assert.Contains("meeting: \"[[../Notes/20260519-meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/20260519-transcript|Transcript]]\"", content);
|
||||
Assert.Contains("summary: \"[[../Summaries/20260519-summary|Summary]]\"", content);
|
||||
Assert.DoesNotContain("assistant_context:", content);
|
||||
Assert.Contains("[[../Notes/20260519-meeting|Meeting Note]]", content);
|
||||
Assert.Contains("[[../Transcripts/20260519-transcript|Transcript]]", content);
|
||||
Assert.Contains("[[../Summaries/20260519-summary|Summary]]", content);
|
||||
@@ -50,9 +57,13 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
"Initial agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
@@ -65,6 +76,8 @@ public sealed class MeetingArtifactStoreTests
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("state: summarizing", content);
|
||||
Assert.Contains("title: Planning Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Initial agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
@@ -82,10 +95,82 @@ public sealed class MeetingArtifactStoreTests
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
"Planning Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"));
|
||||
|
||||
await artifactStore.CreateAssistantContextAsync(artifacts, "", null, CancellationToken.None);
|
||||
await artifactStore.CreateAssistantContextAsync(artifacts, meetingNote, "", null, CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.DoesNotContain("scheduled_end:", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreUpdatesAssistantContextMetadataAndMeetingFrontmatter()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Meeting 2026-05-19 10:00", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
await artifactStore.UpdateAssistantContextMetadataAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Architecture Sync", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"Calendar agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("title: Architecture Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("state: collecting metadata", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Calendar agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreUpdatesAssistantContextMeetingEndAndPreservesCalendarMetadata()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var contextPath = Path.Combine(root, "Meetings", "Assistant Context", "20260519-context.md");
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "20260519-meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
AssistantContextPath: contextPath,
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "20260519-summary.md"));
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create("Architecture Sync", DateTimeOffset.Parse("2026-05-19T10:00:00+02:00")),
|
||||
"Calendar agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
CancellationToken.None);
|
||||
|
||||
await artifactStore.UpdateAssistantContextMeetingAsync(
|
||||
artifacts,
|
||||
MeetingNoteTemplate.Create(
|
||||
"Architecture Sync",
|
||||
DateTimeOffset.Parse("2026-05-19T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-19T10:30:00+02:00")),
|
||||
CancellationToken.None);
|
||||
|
||||
var content = await File.ReadAllTextAsync(contextPath);
|
||||
Assert.Contains("title: Architecture Sync", content);
|
||||
Assert.Contains("start_time: \"2026-05-19T10:00:00.0000000+02:00\"", content);
|
||||
Assert.Contains("end_time: \"2026-05-19T10:30:00.0000000+02:00\"", content);
|
||||
Assert.Contains("agenda: |-", content);
|
||||
Assert.Contains(" Calendar agenda", content);
|
||||
Assert.Contains("scheduled_end: \"2026-05-19T11:00:00.0000000+02:00\"", content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingRunArtifactCleanerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DeleteRunArtifactsDeletesGeneratedScreenshotAttachmentsOnly()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
Path.Combine(root, "Context", "context.md"),
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
var attachments = Path.Combine(Path.GetDirectoryName(artifacts.AssistantContextPath)!, "Attachments");
|
||||
Directory.CreateDirectory(attachments);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.TranscriptPath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
|
||||
var generatedScreenshot = Path.Combine(attachments, "20260527-093135-123-000010.png");
|
||||
var generatedCrop = Path.Combine(attachments, "20260527-093135-123-000010-cropped.png");
|
||||
var manualAttachment = Path.Combine(attachments, "manual-reference.png");
|
||||
var nonImageLinkedFile = Path.Combine(attachments, "20260527-093135-123-000011.png");
|
||||
foreach (var file in new[]
|
||||
{
|
||||
artifacts.MeetingNotePath,
|
||||
artifacts.TranscriptPath,
|
||||
artifacts.SummaryPath,
|
||||
generatedScreenshot,
|
||||
generatedCrop,
|
||||
manualAttachment,
|
||||
nonImageLinkedFile
|
||||
})
|
||||
{
|
||||
await File.WriteAllTextAsync(file, "content");
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||

|
||||

|
||||

|
||||
[Reference](Attachments/20260527-093135-123-000011.png)
|
||||
""");
|
||||
var cleaner = new MeetingRunArtifactCleaner();
|
||||
|
||||
await cleaner.DeleteRunArtifactsAsync(
|
||||
artifacts,
|
||||
new MeetingAssistantOptions(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(File.Exists(artifacts.MeetingNotePath));
|
||||
Assert.False(File.Exists(artifacts.TranscriptPath));
|
||||
Assert.False(File.Exists(artifacts.AssistantContextPath));
|
||||
Assert.False(File.Exists(artifacts.SummaryPath));
|
||||
Assert.False(File.Exists(generatedScreenshot));
|
||||
Assert.False(File.Exists(generatedCrop));
|
||||
Assert.True(File.Exists(manualAttachment));
|
||||
Assert.True(File.Exists(nonImageLinkedFile));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
#pragma warning disable CA1416
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingScreenshotServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CaptureSavesScreenshotAndAppendsTimestampedLink()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync();
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
new CapturingScreenshotOcrClient());
|
||||
|
||||
var result = await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:03:05+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(File.Exists(result.ScreenshotPath));
|
||||
Assert.Equal([1, 2, 3], await File.ReadAllBytesAsync(result.ScreenshotPath));
|
||||
Assert.StartsWith(
|
||||
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
|
||||
result.ScreenshotPath,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("## Screenshot [00:03:05]", context);
|
||||
Assert.Contains(";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureSkipsOcrWhenOcrIsDisabled()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync();
|
||||
var ocr = new CapturingScreenshotOcrClient();
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:05+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, ocr.CallCount);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.DoesNotContain("OCR", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAppendsOcrResultAfterScreenshotWhenConfigured()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
options.Screenshots.Ocr.Prompt = "Extract this meeting screenshot.";
|
||||
});
|
||||
var ocr = new CapturingScreenshotOcrClient("Slide text\n\n```mermaid\ngraph TD\nA-->B\n```");
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, ocr.CallCount);
|
||||
Assert.Equal("Extract this meeting screenshot.", ocr.Prompt);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var imageIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
|
||||
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
|
||||
Assert.True(imageIndex >= 0);
|
||||
Assert.True(ocrIndex > imageIndex);
|
||||
Assert.Contains("Slide text", context);
|
||||
Assert.Contains("```mermaid", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureSavesCroppedImageAndLinksItBeforeOcrWhenOcrReturnsCropCoordinates()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Shared screen text",
|
||||
new ScreenshotCropCoordinates(1, 1, 2, 2));
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture(CreatePngBytes(4, 4)),
|
||||
ocr);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var croppedPath = Assert.Single(Directory.GetFiles(
|
||||
Path.Combine(Path.GetDirectoryName(fixture.Artifacts.AssistantContextPath)!, "Attachments"),
|
||||
"*-cropped.png"));
|
||||
using (var cropped = new Bitmap(croppedPath))
|
||||
{
|
||||
Assert.Equal(2, cropped.Width);
|
||||
Assert.Equal(2, cropped.Height);
|
||||
}
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotIndex = context.IndexOf("![Screenshot 00:00:10]", StringComparison.Ordinal);
|
||||
var cropIndex = context.IndexOf(";
|
||||
var ocrIndex = context.IndexOf("### OCR", StringComparison.Ordinal);
|
||||
Assert.True(screenshotIndex >= 0);
|
||||
Assert.True(cropIndex > screenshotIndex);
|
||||
Assert.True(ocrIndex > cropIndex);
|
||||
Assert.Contains("Shared screen text", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new BlockingScreenshotOcrClient();
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await ocr.WaitUntilStartedAsync();
|
||||
var wait = service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.False(wait.IsCompleted);
|
||||
ocr.Release("OCR complete.");
|
||||
await wait;
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("OCR complete.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelPendingOcrCancelsRunningOcrWithoutWritingFailureText()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new BlockingScreenshotOcrClient();
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await ocr.WaitUntilStartedAsync();
|
||||
|
||||
await service.CancelPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("_OCR pending..._", context);
|
||||
Assert.DoesNotContain("OCR timed out", context);
|
||||
Assert.DoesNotContain("OCR failed", context);
|
||||
}
|
||||
|
||||
private sealed class ScreenshotFixture
|
||||
{
|
||||
private ScreenshotFixture(
|
||||
MeetingAssistantOptions options,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MarkdownMeetingArtifactStore artifactStore)
|
||||
{
|
||||
Options = options;
|
||||
Artifacts = artifacts;
|
||||
ArtifactStore = artifactStore;
|
||||
}
|
||||
|
||||
public MeetingAssistantOptions Options { get; }
|
||||
|
||||
public MeetingSessionArtifacts Artifacts { get; }
|
||||
|
||||
public MarkdownMeetingArtifactStore ArtifactStore { get; }
|
||||
|
||||
public static async Task<ScreenshotFixture> CreateAsync(
|
||||
Action<MeetingAssistantOptions>? configure = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = root,
|
||||
AssistantContextFolder = "Context"
|
||||
}
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
Path.Combine(root, "Context", "context.md"),
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(
|
||||
NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var meeting = MeetingNoteTemplate.Create(
|
||||
"Planning",
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
transcriptPath: artifacts.TranscriptPath,
|
||||
assistantContextPath: artifacts.AssistantContextPath,
|
||||
summaryPath: artifacts.SummaryPath) with
|
||||
{
|
||||
Path = artifacts.MeetingNotePath
|
||||
};
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meeting,
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
return new ScreenshotFixture(options, artifacts, artifactStore);
|
||||
}
|
||||
|
||||
public MeetingScreenshotService CreateService(
|
||||
IActiveWindowScreenshotCapture capture,
|
||||
IScreenshotOcrClient ocrClient)
|
||||
{
|
||||
return new MeetingScreenshotService(
|
||||
capture,
|
||||
ArtifactStore,
|
||||
ocrClient,
|
||||
NullLogger<MeetingScreenshotService>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixedScreenshotCapture : IActiveWindowScreenshotCapture
|
||||
{
|
||||
private readonly byte[] bytes;
|
||||
|
||||
public FixedScreenshotCapture(byte[] bytes)
|
||||
{
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new ActiveWindowScreenshot(bytes, "Demo Window"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly ScreenshotOcrResult result;
|
||||
|
||||
public CapturingScreenshotOcrClient(
|
||||
string text = "",
|
||||
ScreenshotCropCoordinates? crop = null)
|
||||
{
|
||||
result = new ScreenshotOcrResult(text, crop);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public string? Prompt { get; private set; }
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CallCount++;
|
||||
Prompt = prompt;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly TaskCompletionSource started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource<ScreenshotOcrResult> result = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
started.TrySetResult();
|
||||
await using var registration = cancellationToken.Register(() => result.TrySetCanceled(cancellationToken));
|
||||
return await result.Task;
|
||||
}
|
||||
|
||||
public Task WaitUntilStartedAsync()
|
||||
{
|
||||
return started.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void Release(string value)
|
||||
{
|
||||
result.TrySetResult(new ScreenshotOcrResult(value, null));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreatePngBytes(int width, int height)
|
||||
{
|
||||
using var bitmap = new Bitmap(width, height);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.White);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
@@ -73,4 +73,56 @@ public sealed class MeetingSummaryArtifactResolverTests
|
||||
|
||||
Assert.Null(artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolverUsesSuppliedProfileOptionsForSummaryLookup()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var defaultRoot = Path.Combine(root, "default");
|
||||
var englishRoot = Path.Combine(root, "english");
|
||||
var englishNotes = Path.Combine(englishRoot, "Notes");
|
||||
var englishSummaries = Path.Combine(englishRoot, "Summaries");
|
||||
Directory.CreateDirectory(englishNotes);
|
||||
Directory.CreateDirectory(englishSummaries);
|
||||
var notePath = Path.Combine(englishNotes, "meeting.md");
|
||||
await File.WriteAllTextAsync(
|
||||
notePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
attendees:
|
||||
projects:
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
|
||||
User notes.
|
||||
""");
|
||||
var defaultOptions = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = Path.Combine(defaultRoot, "Notes"),
|
||||
SummariesFolder = Path.Combine(defaultRoot, "Summaries")
|
||||
}
|
||||
});
|
||||
var profileOptions = new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
MeetingNotesFolder = englishNotes,
|
||||
SummariesFolder = englishSummaries
|
||||
}
|
||||
};
|
||||
var resolver = new MeetingSummaryArtifactResolver(
|
||||
defaultOptions,
|
||||
new MarkdownMeetingNoteStore(defaultOptions, NullLogger<MarkdownMeetingNoteStore>.Instance));
|
||||
|
||||
var artifacts = await resolver.ResolveBySummaryPathAsync("summary.md", profileOptions, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(artifacts);
|
||||
Assert.Equal(notePath, artifacts.MeetingNotePath);
|
||||
Assert.Equal(Path.Combine(englishSummaries, "summary.md"), artifacts.SummaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,14 +24,27 @@ public sealed class MeetingSummaryFailureWriterTests
|
||||
title: Failure Meeting
|
||||
start_time: "2026-05-20T10:00:00.0000000+02:00"
|
||||
end_time: "2026-05-20T10:30:00.0000000+02:00"
|
||||
attendees: []
|
||||
projects: []
|
||||
attendees:
|
||||
- Ada
|
||||
projects:
|
||||
- Current Project
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.SummaryPath, "# Old Summary");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
"""
|
||||
---
|
||||
attendees:
|
||||
- Preserved
|
||||
projects:
|
||||
- Stale Project
|
||||
---
|
||||
|
||||
# Old Summary
|
||||
""");
|
||||
var writer = new MeetingSummaryFailureWriter(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
@@ -54,7 +67,16 @@ public sealed class MeetingSummaryFailureWriterTests
|
||||
Assert.Equal(artifacts.SummaryPath, result.SummaryPath);
|
||||
Assert.Contains("Summary Generation Failed", content);
|
||||
Assert.Contains("title: Failure Meeting", content);
|
||||
Assert.Contains("attendees:", content);
|
||||
Assert.Contains("- Preserved", content);
|
||||
Assert.DoesNotContain("- Ada", content);
|
||||
Assert.Contains("projects:", content);
|
||||
Assert.Contains("- Current Project", content);
|
||||
Assert.DoesNotContain("- Stale Project", content);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", content);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", content);
|
||||
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", content);
|
||||
Assert.DoesNotContain("summary:", content);
|
||||
Assert.Contains(
|
||||
"[Retry summary generation](http://localhost:5090/meetings/summary/retry?summaryPath=summary.md)",
|
||||
content);
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
@@ -52,12 +54,103 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("title: Meeting", summary);
|
||||
Assert.Contains("start_time: \"2026-05-20T10:00:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("end_time: \"2026-05-20T10:30:00.0000000+02:00\"", summary);
|
||||
Assert.Contains("attendees:", summary);
|
||||
Assert.Contains("- Ada", summary);
|
||||
Assert.Contains("projects:", summary);
|
||||
Assert.Contains("- Meeting Assistant", summary);
|
||||
Assert.Contains("meeting: \"[[../Notes/meeting|Meeting Note]]\"", summary);
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", summary);
|
||||
Assert.Contains("assistant_context: \"[[../Assistant Context/context|Assistant Context]]\"", summary);
|
||||
Assert.DoesNotContain("summary:", summary);
|
||||
Assert.Contains("# Summary\n\n- Done.", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSummaryPreservesExistingSummaryAttendees()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
attendees:
|
||||
- Ada
|
||||
- Grace
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
"""
|
||||
---
|
||||
title: Existing
|
||||
attendees:
|
||||
- Preserved
|
||||
---
|
||||
|
||||
Old summary.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("attendees:", summary);
|
||||
Assert.Contains("- Preserved", summary);
|
||||
Assert.DoesNotContain("- Ada", summary);
|
||||
Assert.DoesNotContain("- Grace", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSummaryCopiesCurrentMeetingProjectsOverExistingSummaryProjects()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
projects:
|
||||
- Current Project
|
||||
- Second Project
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
"""
|
||||
---
|
||||
title: Existing
|
||||
projects:
|
||||
- Stale Project
|
||||
---
|
||||
|
||||
Old summary.
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteSummary("# Summary");
|
||||
|
||||
var summary = await File.ReadAllTextAsync(artifacts.SummaryPath);
|
||||
Assert.Contains("projects:", summary);
|
||||
Assert.Contains("- Current Project", summary);
|
||||
Assert.Contains("- Second Project", summary);
|
||||
Assert.DoesNotContain("- Stale Project", summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadToolsSupportClampedLineRanges()
|
||||
{
|
||||
@@ -142,4 +235,105 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.Contains("replacement\ninserted\nline two", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsWriteAssistantContextStripsAccidentalFrontmatterFromReplacementBody()
|
||||
{
|
||||
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.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
"""
|
||||
---
|
||||
meeting: "[[../Notes/meeting|Meeting Note]]"
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
state: summarizing
|
||||
---
|
||||
|
||||
line one
|
||||
""");
|
||||
var tools = new MeetingSummaryTools(artifacts);
|
||||
|
||||
await tools.WriteContext(
|
||||
"""
|
||||
---
|
||||
state: finished
|
||||
---
|
||||
|
||||
# Assistant Context
|
||||
|
||||
## Live Context
|
||||
""");
|
||||
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("state: summarizing", context);
|
||||
Assert.DoesNotContain("state: finished", context);
|
||||
Assert.Equal(2, context.Split("---", StringSplitOptions.None).Length - 1);
|
||||
Assert.Contains("# Assistant Context", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExternalAgentWritesAreAppendedToAssistantContextAsDiffs()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var projectsRoot = Path.Combine(root, "Projects");
|
||||
var projectRoot = Path.Combine(projectsRoot, "MeetingAssistant");
|
||||
var projectFile = Path.Combine(projectRoot, "notes.md");
|
||||
var dictionaryPath = Path.Combine(root, "Meetings", "Dictionary.md");
|
||||
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(projectRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
await File.WriteAllTextAsync(projectFile, "one\ntwo\nthree");
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
projects:
|
||||
- MeetingAssistant
|
||||
---
|
||||
""");
|
||||
await File.WriteAllTextAsync(artifacts.AssistantContextPath, "Context before.");
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Vault =
|
||||
{
|
||||
DictationWordsPath = dictionaryPath,
|
||||
ProjectsFolder = projectsRoot
|
||||
}
|
||||
};
|
||||
var audit = new SummaryAgentWriteAudit(artifacts);
|
||||
var tools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
options,
|
||||
new MarkdownDictationWordStore(Options.Create(options)),
|
||||
audit);
|
||||
|
||||
await tools.WriteContext("owned context");
|
||||
await tools.WriteSummary("owned summary");
|
||||
await tools.WriteProjectFile("MeetingAssistant", "notes.md", "TWO", from: 2, to: 2);
|
||||
await tools.AddDictationWord("PBI");
|
||||
await audit.AppendToAssistantContextAsync(CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
|
||||
Assert.Contains("## Summary Agent External File Changes", context);
|
||||
Assert.Contains(projectFile, context);
|
||||
Assert.Contains("- two", context);
|
||||
Assert.Contains("+ TWO", context);
|
||||
Assert.Contains(dictionaryPath, context);
|
||||
Assert.Contains("+ - PBI", context);
|
||||
Assert.DoesNotContain("owned summary", context);
|
||||
Assert.DoesNotContain("- Context before.", context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingWorkflowDiagnosticEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReloadEndpointReloadsAutomationRulesPathFromConfigurationFile()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var configPath = Path.Combine(root, "workflow-config.json");
|
||||
var firstRulesPath = Path.Combine(root, "first-rules.yaml");
|
||||
var secondRulesPath = Path.Combine(root, "second-rules.yaml");
|
||||
await File.WriteAllTextAsync(configPath, CreateConfigJson(firstRulesPath));
|
||||
await using var factory = new WebApplicationFactory<Program>()
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
configuration.AddJsonFile(configPath, optional: false, reloadOnChange: false);
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MeetingAssistant:FunAsr:Backend:Enabled"] = "false"
|
||||
});
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var firstResponse = await client.PostAsync("/diagnostics/workflow/reload", null);
|
||||
var firstBody = await firstResponse.Content.ReadFromJsonAsync<WorkflowReloadResponse>();
|
||||
await File.WriteAllTextAsync(configPath, CreateConfigJson(secondRulesPath));
|
||||
|
||||
using var secondResponse = await client.PostAsync("/diagnostics/workflow/reload", null);
|
||||
var secondBody = await secondResponse.Content.ReadFromJsonAsync<WorkflowReloadResponse>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode);
|
||||
Assert.Equal(firstRulesPath, firstBody?.RulesPath);
|
||||
Assert.Equal(HttpStatusCode.OK, secondResponse.StatusCode);
|
||||
Assert.Equal(secondRulesPath, secondBody?.RulesPath);
|
||||
}
|
||||
|
||||
private static string CreateConfigJson(string rulesPath)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
MeetingAssistant = new
|
||||
{
|
||||
Automation = new
|
||||
{
|
||||
RulesPath = rulesPath
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sealed record WorkflowReloadResponse(string? RulesPath);
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MeetingWorkflowEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreatedRuleAddsDefaultAttendeeOnceWhenMeetingHasNoAttendees()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: add-me
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: meeting.attendees.count = 0
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Manuel"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateTransitionRuleCanCleanTitleAndAddProjectWithNestedConditions()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: clean-teams-title
|
||||
on:
|
||||
- state_transition:
|
||||
from: collecting metadata
|
||||
to: transcribing
|
||||
if:
|
||||
- and:
|
||||
- condition: contains(meeting.title, '[Teams]')
|
||||
- not:
|
||||
condition: contains(meeting.projects, 'Architecture')
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: title
|
||||
value: '@Model.Meeting.Title.Replace("[Teams]", "").Trim()'
|
||||
- uses: add_project
|
||||
value: Architecture
|
||||
""",
|
||||
title: "[Teams] Architecture Sync");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.StateTransition(
|
||||
fixture.Artifacts,
|
||||
AssistantContextState.CollectingMetadata,
|
||||
AssistantContextState.Transcribing),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal("Architecture Sync", meeting.Frontmatter.Title);
|
||||
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StepValuesWithEmailAddressesAreTreatedAsLiteralText()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: normalize-sew-sofi-daily
|
||||
on:
|
||||
- state_transition:
|
||||
from: collecting metadata
|
||||
to: transcribing
|
||||
if:
|
||||
- condition: "meeting.title = 'WG: Sofi - Daily - Team Velocity Vanguards'"
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: title
|
||||
value: 'SEW Sofi - Daily'
|
||||
- uses: add_project
|
||||
value: 'SEW Solution Finder'
|
||||
- uses: add_attendee
|
||||
value: 'niklas.link.e@sew-eurodrive.de'
|
||||
""",
|
||||
title: "WG: Sofi - Daily - Team Velocity Vanguards");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.StateTransition(
|
||||
fixture.Artifacts,
|
||||
AssistantContextState.CollectingMetadata,
|
||||
AssistantContextState.Transcribing),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal("SEW Sofi - Daily", meeting.Frontmatter.Title);
|
||||
Assert.Equal(["SEW Solution Finder"], meeting.Frontmatter.Projects);
|
||||
Assert.Equal(["niklas.link.e@sew-eurodrive.de"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StepValuesCanMixEmailAddressesAndRazorTemplates()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: mixed-template
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: 'Contact Support@contoso.com about @Model.Meeting.Title.'
|
||||
""",
|
||||
title: "Architecture Sync");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Contact Support@contoso.com about Architecture Sync.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StepValuesRenderRazorTransitionsThatAreNotModelMarkers()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: razor-transition
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: 'Generated in @System.DateTime.UtcNow.Year'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains($"Generated in {DateTime.UtcNow.Year}", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerIdentifiedRuleFiltersByNameAndWritesRazorContext()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: ada-context
|
||||
on:
|
||||
- speaker_identified:
|
||||
name: Ada
|
||||
if:
|
||||
- or:
|
||||
- condition: contains(meeting.title, 'Architecture')
|
||||
- condition: meeting.attendees.count > 3
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: 'Speaker @Model.Speaker.Name was identified in @Model.Meeting.Title.'
|
||||
""",
|
||||
title: "Architecture Sync");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Grace"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.DoesNotContain("Grace", context);
|
||||
Assert.Contains("Speaker Ada was identified in Architecture Sync.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: remove-placeholder
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- and:
|
||||
- condition: contains(meeting.attendees, 'Placeholder')
|
||||
- not:
|
||||
condition: contains(meeting.title, 'Keep Placeholder')
|
||||
steps:
|
||||
- uses: remove_attendee
|
||||
value: Placeholder
|
||||
""",
|
||||
attendees: ["Placeholder", "Ada"]);
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("created: {}", "created", null, null, null, true)]
|
||||
[InlineData("created: {}", "speaker", null, null, "Ada", false)]
|
||||
[InlineData("state_transition:\n from: collecting metadata", "state", "collecting metadata", "speaker recognition", null, true)]
|
||||
[InlineData("state_transition:\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("state_transition:\n from: collecting metadata\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("state_transition:\n from: transcribing\n to: summarizing", "state", "collecting metadata", "transcribing", null, false)]
|
||||
[InlineData("state_transition:\n from: COLLECTING METADATA\n to: TRANSCRIBING", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)]
|
||||
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)]
|
||||
[InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)]
|
||||
public async Task TriggerMatchingScenarios(
|
||||
string triggerYaml,
|
||||
string eventKind,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker,
|
||||
bool shouldRun)
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
$$"""
|
||||
rules:
|
||||
- name: trigger-scenario
|
||||
on:
|
||||
- {{triggerYaml}}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Triggered
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("event.type = 'SpeakerIdentified'", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("state.from = 'collecting metadata' and state.to = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("meeting.state = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)]
|
||||
[InlineData("speaker.name = 'Ada'", "speaker", null, null, "Ada", true)]
|
||||
[InlineData("speaker.name = 'Ada'", "speaker", null, null, "Grace", false)]
|
||||
[InlineData("starts_with(meeting.title, 'planning')", "created", null, null, null, true)]
|
||||
[InlineData("ends_with(meeting.title, 'sync')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.title, 'ANNING S')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.attendees, 'Ada')", "created", null, null, null, true)]
|
||||
[InlineData("contains(meeting.projects, 'Architecture')", "created", null, null, null, true)]
|
||||
[InlineData("meeting.attendees.count >= 2", "created", null, null, null, true)]
|
||||
public async Task ExpressionConditionParameterScenarios(
|
||||
string condition,
|
||||
string eventKind,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker,
|
||||
bool shouldRun)
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
$$"""
|
||||
rules:
|
||||
- name: condition-scenario
|
||||
on:
|
||||
- created: {}
|
||||
- state_transition: {}
|
||||
- speaker_identified: {}
|
||||
if:
|
||||
- condition: {{condition}}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ConditionMatched
|
||||
""",
|
||||
title: "Planning Sync",
|
||||
attendees: ["Ada", "Grace"],
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("ConditionMatched"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TopLevelConditionsAreCombinedWithAnd()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: all-top-level-conditions
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: contains(meeting.title, 'Planning')
|
||||
- condition: contains(meeting.attendees, 'Ada')
|
||||
- condition: contains(meeting.projects, 'Missing')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ShouldNotRun
|
||||
""",
|
||||
attendees: ["Ada"],
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrGroupSkipsRuleWhenAllBranchesAreFalse()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: false-or
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- or:
|
||||
- condition: contains(meeting.title, 'Budget')
|
||||
- condition: contains(meeting.attendees, 'Grace')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: ShouldNotRun
|
||||
""",
|
||||
attendees: ["Ada"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyConditionListRunsWhenTriggerMatches()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: no-if
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: NoConditionNeeded
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Contains("NoConditionNeeded", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RuleWithMultipleTriggersRunsWhenAnyTriggerMatches()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: multi-trigger
|
||||
on:
|
||||
- created: {}
|
||||
- speaker_identified:
|
||||
name: Ada
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: MultiTrigger
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Contains("MultiTrigger", meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultipleRulesRunInFileOrderAndLaterRulesSeeEarlierMutations()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: first
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
- name: second
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: contains(meeting.attendees, 'Ada')
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Architecture
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada"], meeting.Frontmatter.Attendees);
|
||||
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LaterStepsInSameRuleSeeEarlierStepMutationsInRazorModel()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: step-order
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
- uses: add_context
|
||||
value: 'Attendees: @Model.Meeting.Attendees.Count'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Attendees: 1", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpeakerIdentifiedTemplateCanUseRecognizedSpeakerName()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: speaker-template
|
||||
on:
|
||||
- speaker_identified: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: '@Model.Speaker.Name joined @Model.Meeting.Title'
|
||||
""",
|
||||
title: "Design Review");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Manuel"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Manuel joined Design Review", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StateTransitionTemplateCanUseFromAndToStates()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: transition-template
|
||||
on:
|
||||
- state_transition:
|
||||
to: transcribing
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: '@Model.Event.From -> @Model.Event.To'
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.StateTransition(
|
||||
fixture.Artifacts,
|
||||
AssistantContextState.CollectingMetadata,
|
||||
AssistantContextState.Transcribing),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("collecting metadata -> transcribing", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttendeeNormalizesDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: normalize-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: 'Ada Lovelace <ada@example.com>'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: dedupe-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Ada
|
||||
""",
|
||||
attendees: ["Ada <ada@example.com>"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada <ada@example.com>"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAttendeeMatchesExistingDisplayNameFromEmailAddress()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: remove-email-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: remove_attendee
|
||||
value: Ada
|
||||
""",
|
||||
attendees: ["Ada <ada@example.com>", "Grace"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Grace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddProjectDoesNotDuplicateCaseInsensitiveProject()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: dedupe-project
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: architecture
|
||||
""",
|
||||
projects: ["Architecture"]);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Architecture"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPropertyCanUseNameAliasForTitle()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: title-by-name
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: set_property
|
||||
name: meeting.title
|
||||
value: Updated Title
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal("Updated Title", meeting.Frontmatter.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddContextDoesNotRequireMeetingNoteMutationToPersist()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: context-only
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: Context-only note.
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var context = await fixture.ReadContextAsync();
|
||||
Assert.Contains("Context-only note.", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesFileChangesAreReadForTheNextWorkflowEvent()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
Directory.CreateDirectory(root);
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: first-version
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: First
|
||||
""",
|
||||
rulesPath: rulesPath);
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
await File.WriteAllTextAsync(
|
||||
rulesPath,
|
||||
"""
|
||||
rules:
|
||||
- name: second-version
|
||||
on:
|
||||
- speaker_identified: {}
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Second
|
||||
""");
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["First", "Second"], meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlankRulesFileDoesNothing()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(" ");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Empty(meeting.Frontmatter.Attendees);
|
||||
Assert.Empty(meeting.Frontmatter.Projects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingRulesFileDoesNothing()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync("", rulesPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.yaml"));
|
||||
|
||||
await fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Empty(meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
private static Task RunCreatedAsync(WorkflowFixture fixture)
|
||||
{
|
||||
return fixture.Engine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(fixture.Artifacts),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private static MeetingWorkflowEvent CreateEvent(
|
||||
string eventKind,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string? from,
|
||||
string? to,
|
||||
string? speaker)
|
||||
{
|
||||
return eventKind switch
|
||||
{
|
||||
"created" => MeetingWorkflowEvent.Created(artifacts),
|
||||
"speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"),
|
||||
"state" => MeetingWorkflowEvent.StateTransition(
|
||||
artifacts,
|
||||
ParseState(from ?? "collecting metadata"),
|
||||
ParseState(to ?? "transcribing")),
|
||||
_ => throw new InvalidOperationException($"Unknown workflow event kind '{eventKind}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static AssistantContextState ParseState(string state)
|
||||
{
|
||||
return state.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"collecting metadata" => AssistantContextState.CollectingMetadata,
|
||||
"transcribing" => AssistantContextState.Transcribing,
|
||||
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
||||
"summarizing" => AssistantContextState.Summarizing,
|
||||
"finished" => AssistantContextState.Finished,
|
||||
"error" => AssistantContextState.Error,
|
||||
_ => throw new InvalidOperationException($"Unknown workflow state '{state}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WorkflowFixture
|
||||
{
|
||||
private WorkflowFixture(
|
||||
MeetingAssistantOptions options,
|
||||
MarkdownMeetingNoteStore noteStore,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingWorkflowEngine engine)
|
||||
{
|
||||
Options = options;
|
||||
NoteStore = noteStore;
|
||||
Artifacts = artifacts;
|
||||
Engine = engine;
|
||||
}
|
||||
|
||||
public MeetingAssistantOptions Options { get; }
|
||||
|
||||
public MarkdownMeetingNoteStore NoteStore { get; }
|
||||
|
||||
public MeetingSessionArtifacts Artifacts { get; }
|
||||
|
||||
public MeetingWorkflowEngine Engine { get; }
|
||||
|
||||
public static async Task<WorkflowFixture> CreateAsync(
|
||||
string rulesYaml,
|
||||
string title = "Planning Sync",
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
IReadOnlyList<string>? projects = null,
|
||||
string? rulesPath = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
rulesPath ??= Path.Combine(root, "rules.yaml");
|
||||
if (!string.IsNullOrEmpty(rulesYaml))
|
||||
{
|
||||
await File.WriteAllTextAsync(rulesPath, rulesYaml);
|
||||
}
|
||||
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions
|
||||
{
|
||||
BaseFolder = root,
|
||||
MeetingNotesFolder = "Notes",
|
||||
TranscriptsFolder = "Transcripts",
|
||||
AssistantContextFolder = "Context",
|
||||
SummariesFolder = "Summaries",
|
||||
ProjectsFolder = "Projects"
|
||||
},
|
||||
Automation = new AutomationOptions
|
||||
{
|
||||
RulesPath = rulesPath
|
||||
}
|
||||
};
|
||||
var noteStore = new MarkdownMeetingNoteStore(
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(
|
||||
NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
Path.Combine(root, "Context", "context.md"),
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
var meeting = await noteStore.SaveAsync(
|
||||
MeetingNoteTemplate.Create(
|
||||
title,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
attendees: attendees ?? [],
|
||||
projects: projects ?? [],
|
||||
transcriptPath: artifacts.TranscriptPath,
|
||||
assistantContextPath: artifacts.AssistantContextPath,
|
||||
summaryPath: artifacts.SummaryPath) with
|
||||
{
|
||||
Path = artifacts.MeetingNotePath
|
||||
},
|
||||
options,
|
||||
CancellationToken.None);
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
meeting,
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
var engine = new MeetingWorkflowEngine(
|
||||
new FileMeetingWorkflowRulesProvider(
|
||||
NullLogger<FileMeetingWorkflowRulesProvider>.Instance),
|
||||
noteStore,
|
||||
artifactStore,
|
||||
NullLogger<MeetingWorkflowEngine>.Instance);
|
||||
return new WorkflowFixture(options, noteStore, artifacts, engine);
|
||||
}
|
||||
|
||||
public Task<MeetingNote> ReadMeetingAsync()
|
||||
{
|
||||
return NoteStore.ReadAsync(Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task<string> ReadContextAsync()
|
||||
{
|
||||
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,13 +17,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
aliases: ["M. Schweigert"],
|
||||
snippets: [[1], [2], [3]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddMonths(-2),
|
||||
transcriptionCount: 5);
|
||||
referenceCount: 5);
|
||||
var recent = await fixture.AddIdentityAsync(
|
||||
canonicalName: "Guest Manuel",
|
||||
aliases: [],
|
||||
snippets: [[4], [5]],
|
||||
createdAt: DateTimeOffset.UtcNow.AddDays(-1),
|
||||
transcriptionCount: 2);
|
||||
referenceCount: 2);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
fixture.Matcher.MatchIdentityIds.Enqueue(older.Id);
|
||||
var service = fixture.CreateService();
|
||||
@@ -36,7 +36,9 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
Assert.Equal([5], fixture.Matcher.Requests[1].UnknownSnippet);
|
||||
var savedOlder = await fixture.LoadIdentityAsync(older.Id);
|
||||
Assert.Equal("Manuel", savedOlder.CanonicalName);
|
||||
Assert.Equal(7, savedOlder.TranscriptionCount);
|
||||
Assert.Equal(7, savedOlder.References.Count);
|
||||
Assert.All(savedOlder.References, reference =>
|
||||
Assert.Contains("Manuel and Guest Manuel were merged", File.ReadAllText(reference.TranscriptPath)));
|
||||
Assert.True(savedOlder.UpdatedAt > older.UpdatedAt);
|
||||
Assert.Equal(["Guest Manuel", "M. Schweigert"], savedOlder.Aliases.Select(alias => alias.Name).Order());
|
||||
Assert.Equal(3, savedOlder.Snippets.Count);
|
||||
@@ -74,10 +76,12 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
|
||||
private sealed class SpeakerIdentityMergeFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
|
||||
private SpeakerIdentityMergeFixture(string dbPath, SpeakerIdentityDbContext context)
|
||||
private SpeakerIdentityMergeFixture(string tempDirectory, string dbPath, SpeakerIdentityDbContext context)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
}
|
||||
@@ -88,13 +92,14 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
|
||||
public static async Task<SpeakerIdentityMergeFixture> CreateAsync()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
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 SpeakerIdentityMergeFixture(dbPath, context);
|
||||
return new SpeakerIdentityMergeFixture(tempDirectory, dbPath, context);
|
||||
}
|
||||
|
||||
public SpeakerIdentityMergeService CreateService()
|
||||
@@ -118,14 +123,13 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
IReadOnlyList<string> aliases,
|
||||
IReadOnlyList<byte[]> snippets,
|
||||
DateTimeOffset createdAt,
|
||||
int transcriptionCount)
|
||||
int referenceCount)
|
||||
{
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = createdAt,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
Aliases = aliases.Select(alias => new SpeakerAlias { Name = alias }).ToList(),
|
||||
Snippets = snippets
|
||||
.Select((snippet, index) => new SpeakerSnippet
|
||||
@@ -133,6 +137,19 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
WavBytes = snippet,
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
})
|
||||
.ToList(),
|
||||
References = Enumerable.Range(0, referenceCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"),
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = createdAt.AddMinutes(index)
|
||||
};
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
@@ -146,15 +163,16 @@ public sealed class SpeakerIdentityMergeServiceTests
|
||||
return Context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task MatchedUnnamedIdentityEliminatesCandidatesAndPromotesCanonicalName()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3]);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 2);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -27,7 +27,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("John", saved.CanonicalName);
|
||||
Assert.Equal(["John"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Equal(1, saved.TranscriptionCount);
|
||||
Assert.Equal(3, saved.References.Count);
|
||||
Assert.All(saved.References, reference =>
|
||||
Assert.Contains("Guest01 was identified as John", File.ReadAllText(reference.TranscriptPath)));
|
||||
Assert.Equal("John", result.Segments.Single().Speaker);
|
||||
Assert.Equal("John", result.SpeakerMappings["Guest01"]);
|
||||
}
|
||||
@@ -41,7 +43,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
[],
|
||||
[1, 2, 3],
|
||||
"Chris",
|
||||
transcriptionCount: 5,
|
||||
referenceCount: 5,
|
||||
updatedAt: oldTimestamp);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
@@ -54,6 +56,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.True(saved.UpdatedAt > oldTimestamp);
|
||||
Assert.Equal(6, saved.References.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -73,6 +76,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["Chris", "Jane"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Single(saved.References);
|
||||
Assert.Equal([7, 8, 9], saved.Snippets.Single().WavBytes);
|
||||
}
|
||||
|
||||
@@ -93,13 +97,28 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Equal("Michael", saved.CanonicalName);
|
||||
Assert.Equal(["Michael"], saved.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Contains("Guest01 was identified as Michael", File.ReadAllText(saved.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendeeCanonicalizerDeduplicatesExactIdentityAliases()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
await fixture.AddIdentityAsync([], [1, 2, 3], "Karl Berger", aliases: ["Berger, Karl"]);
|
||||
var canonicalizer = fixture.CreateAttendeeCanonicalizer();
|
||||
|
||||
var attendees = await canonicalizer.CanonicalizeAsync(
|
||||
["Karl Berger <karl.work@example.com>", "Berger, Karl <karl.private@example.com>", "Ada"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(["Karl Berger", "Ada"], attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedDiarizedSpeakersCreateCandidateIdentitiesFromUnmatchedAttendees()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -115,6 +134,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.OrderBy(identity => identity.Id)
|
||||
.ToListAsync();
|
||||
@@ -124,6 +144,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
{
|
||||
Assert.NotEqual(default, identity.CreatedAt);
|
||||
Assert.NotEqual(default, identity.UpdatedAt);
|
||||
Assert.Single(identity.References);
|
||||
Assert.Equal(["John", "Mike"], identity.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
});
|
||||
}
|
||||
@@ -132,7 +153,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task UnmatchedSpeakersExcludeAliasesOfMatchedIdentitiesFromCandidates()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", transcriptionCount: 5, aliases: ["Mike"]);
|
||||
var michael = await fixture.AddIdentityAsync([], [1, 2, 3], "Michael", referenceCount: 5, aliases: ["Mike"]);
|
||||
fixture.Matcher.MatchIdentityId = michael.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -147,16 +168,42 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Where(identity => identity.CanonicalName == null)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => identity.CanonicalName == "Jane")
|
||||
.SingleAsync();
|
||||
Assert.Equal("Jane", learned.CanonicalName);
|
||||
Assert.Equal(["Jane"], learned.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Single(learned.References);
|
||||
Assert.Contains("Guest01 was identified as Jane", File.ReadAllText(learned.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnmatchedSpeakerWithSingleRemainingCandidateIsPromotedOnInsert()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel"],
|
||||
[new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest01", "unknown")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var learned = await fixture.Context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync();
|
||||
Assert.Equal("Manuel", learned.CanonicalName);
|
||||
Assert.Equal(["Manuel"], learned.CandidateNames.Select(candidate => candidate.Name));
|
||||
Assert.Single(learned.References);
|
||||
Assert.Contains("Guest01 was identified as Manuel", File.ReadAllText(learned.References.Single().TranscriptPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuppliedSpeakerSamplesAreUsedBeforeExtractingFromAudioFile()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", transcriptionCount: 5);
|
||||
var chris = await fixture.AddIdentityAsync([], [1, 2, 3], "Chris", referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = chris.Id;
|
||||
var service = fixture.CreateService();
|
||||
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest03", "hello from the live buffer");
|
||||
@@ -177,7 +224,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task LiveKnownSpeakerIdentificationDoesNotMutateIdentityDatabase()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], transcriptionCount: 5);
|
||||
var identity = await fixture.AddIdentityAsync(["John", "Mike"], [1, 2, 3], referenceCount: 5);
|
||||
fixture.Matcher.MatchIdentityId = identity.Id;
|
||||
var service = fixture.CreateService();
|
||||
|
||||
@@ -190,7 +237,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var saved = await fixture.LoadIdentityAsync(identity.Id);
|
||||
Assert.Null(saved.CanonicalName);
|
||||
Assert.Equal(["John", "Mike"], saved.CandidateNames.Select(candidate => candidate.Name).Order());
|
||||
Assert.Equal(5, saved.TranscriptionCount);
|
||||
Assert.Equal(5, saved.References.Count);
|
||||
Assert.Single(saved.Snippets);
|
||||
Assert.Equal("Guest01", result.Segments.Single().Speaker);
|
||||
Assert.Empty(result.SpeakerMappings);
|
||||
@@ -200,9 +247,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public async Task SpeakerMatchingPrioritizesAttendeeDisplayNamesAndAliases()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", transcriptionCount: 99);
|
||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", transcriptionCount: 1, aliases: ["Mike"]);
|
||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", transcriptionCount: 2);
|
||||
var unrelated = await fixture.AddIdentityAsync([], [1], "Frequent", referenceCount: 99);
|
||||
var aliasMatch = await fixture.AddIdentityAsync([], [2], "Michael", referenceCount: 1, aliases: ["Mike"]);
|
||||
var displayNameMatch = await fixture.AddIdentityAsync([], [3], "Jane", referenceCount: 2);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
@@ -223,10 +270,10 @@ public sealed class SpeakerIdentityServiceTests
|
||||
options.MaxMatchCandidates = 2;
|
||||
options.MatchIdentityActiveAge = TimeSpan.FromDays(365);
|
||||
});
|
||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", transcriptionCount: 50);
|
||||
await fixture.AddIdentityAsync([], [2], "Stale High", transcriptionCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", transcriptionCount: 5);
|
||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", transcriptionCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||
var activeHigh = await fixture.AddIdentityAsync([], [1], "Active High", referenceCount: 50);
|
||||
await fixture.AddIdentityAsync([], [2], "Stale High", referenceCount: 100, updatedAt: DateTimeOffset.UtcNow.AddYears(-2));
|
||||
var activeLow = await fixture.AddIdentityAsync([], [3], "Active Low", referenceCount: 5);
|
||||
var staleAttendee = await fixture.AddIdentityAsync([], [4], "Former Employee", referenceCount: 1, updatedAt: DateTimeOffset.UtcNow.AddYears(-2), aliases: ["Former"]);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
@@ -240,6 +287,68 @@ public sealed class SpeakerIdentityServiceTests
|
||||
Assert.DoesNotContain(activeLow.Id, requestedIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KnownSpeakerMappingsExcludeMappedSpeakersAndIdentitiesFromMatching()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99);
|
||||
var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.IdentifyKnownSpeakersAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel", "Daniel"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved")
|
||||
],
|
||||
[
|
||||
new SpeakerAudioSample(
|
||||
"Guest-2",
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Guest-2", "already identified"),
|
||||
[4],
|
||||
90),
|
||||
new SpeakerAudioSample(
|
||||
"Guest-1",
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved"),
|
||||
[5],
|
||||
90)
|
||||
],
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Guest-2"] = "Manuel"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
var request = fixture.Matcher.Requests.Single();
|
||||
Assert.Equal("Guest-1", request.DiarizedSpeaker);
|
||||
Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NamedSpeakersExcludeTheirIdentityFromFurtherMatching()
|
||||
{
|
||||
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
|
||||
var manuel = await fixture.AddIdentityAsync([], [1], "Manuel", referenceCount: 99);
|
||||
var daniel = await fixture.AddIdentityAsync([], [2], "Daniel", referenceCount: 5);
|
||||
var service = fixture.CreateService();
|
||||
|
||||
await service.ProcessFinishedTranscriptAsync(
|
||||
fixture.CreateRequest(
|
||||
["Manuel", "Daniel"],
|
||||
[
|
||||
new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(4), "Manuel", "already relabeled"),
|
||||
new TranscriptionSegment(TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(8), "Guest-1", "unresolved")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
var request = fixture.Matcher.Requests.Single();
|
||||
Assert.Equal("Guest-1", request.DiarizedSpeaker);
|
||||
Assert.Equal([daniel.Id], request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
Assert.DoesNotContain(manuel.Id, request.Candidates.Select(candidate => candidate.IdentityId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SchemaUpgradeAddsLastModifiedColumnInitializedFromCreatedAt()
|
||||
{
|
||||
@@ -275,14 +384,17 @@ public sealed class SpeakerIdentityServiceTests
|
||||
|
||||
private sealed class SpeakerIdentityFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly string tempDirectory;
|
||||
private readonly string dbPath;
|
||||
private readonly SpeakerIdentificationOptions options;
|
||||
|
||||
private SpeakerIdentityFixture(
|
||||
string tempDirectory,
|
||||
string dbPath,
|
||||
SpeakerIdentityDbContext context,
|
||||
SpeakerIdentificationOptions options)
|
||||
{
|
||||
this.tempDirectory = tempDirectory;
|
||||
this.dbPath = dbPath;
|
||||
Context = context;
|
||||
this.options = options;
|
||||
@@ -297,8 +409,9 @@ public sealed class SpeakerIdentityServiceTests
|
||||
public static async Task<SpeakerIdentityFixture> CreateAsync(
|
||||
Action<SpeakerIdentificationOptions>? configureOptions = null)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", $"{Guid.NewGuid():N}.db");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||
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 options = new SpeakerIdentificationOptions
|
||||
{
|
||||
DatabasePath = dbPath,
|
||||
@@ -310,7 +423,7 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
||||
.Options);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None);
|
||||
return new SpeakerIdentityFixture(dbPath, context, options);
|
||||
return new SpeakerIdentityFixture(tempDirectory, dbPath, context, options);
|
||||
}
|
||||
|
||||
public SpeakerIdentityService CreateService()
|
||||
@@ -323,29 +436,44 @@ public sealed class SpeakerIdentityServiceTests
|
||||
NullLogger<SpeakerIdentityService>.Instance);
|
||||
}
|
||||
|
||||
public SpeakerIdentityAttendeeCanonicalizer CreateAttendeeCanonicalizer()
|
||||
{
|
||||
return new SpeakerIdentityAttendeeCanonicalizer(new TestSpeakerIdentityDbContextFactory(dbPath));
|
||||
}
|
||||
|
||||
public SpeakerIdentificationRequest CreateRequest(
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlyList<TranscriptionSegment> segments,
|
||||
IReadOnlyList<SpeakerAudioSample>? samples = null)
|
||||
IReadOnlyList<SpeakerAudioSample>? samples = null,
|
||||
IReadOnlyDictionary<string, string>? knownSpeakerMappings = null)
|
||||
{
|
||||
var meetingNotePath = Path.Combine(tempDirectory, "meeting.md");
|
||||
var transcriptPath = Path.Combine(tempDirectory, "transcript.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentificationRequest(
|
||||
"test.wav",
|
||||
MeetingNoteTemplate.Create(
|
||||
"Test Meeting",
|
||||
DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
attendees: attendees,
|
||||
transcriptPath: "transcript.md",
|
||||
assistantContextPath: "context.md",
|
||||
summaryPath: "summary.md"),
|
||||
new MeetingNote(
|
||||
meetingNotePath,
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Test Meeting",
|
||||
StartTime = DateTimeOffset.Parse("2026-05-20T12:00:00+02:00"),
|
||||
Attendees = attendees.ToList(),
|
||||
Transcript = transcriptPath,
|
||||
AssistantContext = Path.Combine(tempDirectory, "context.md"),
|
||||
Summary = Path.Combine(tempDirectory, "summary.md")
|
||||
},
|
||||
""),
|
||||
segments,
|
||||
samples);
|
||||
samples,
|
||||
knownSpeakerMappings);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentity> AddIdentityAsync(
|
||||
IReadOnlyList<string> candidates,
|
||||
byte[] snippet,
|
||||
string? canonicalName = null,
|
||||
int transcriptionCount = 0,
|
||||
int referenceCount = 0,
|
||||
IReadOnlyList<string>? aliases = null,
|
||||
DateTimeOffset? updatedAt = null)
|
||||
{
|
||||
@@ -353,7 +481,6 @@ public sealed class SpeakerIdentityServiceTests
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
TranscriptionCount = transcriptionCount,
|
||||
CreatedAt = timestamp,
|
||||
UpdatedAt = timestamp,
|
||||
Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [],
|
||||
@@ -365,7 +492,20 @@ public sealed class SpeakerIdentityServiceTests
|
||||
WavBytes = snippet,
|
||||
CreatedAt = timestamp.AddMinutes(-10)
|
||||
}
|
||||
]
|
||||
],
|
||||
References = Enumerable.Range(0, referenceCount)
|
||||
.Select(index =>
|
||||
{
|
||||
var transcriptPath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md");
|
||||
File.WriteAllText(transcriptPath, "Transcript");
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = Path.Combine(tempDirectory, $"reference-{Guid.NewGuid():N}-{index}.md"),
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = timestamp.AddMinutes(index)
|
||||
};
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
Context.SpeakerIdentities.Add(identity);
|
||||
await Context.SaveChangesAsync();
|
||||
@@ -379,15 +519,16 @@ public sealed class SpeakerIdentityServiceTests
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleAsync(identity => identity.Id == id);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Context.DisposeAsync();
|
||||
if (File.Exists(dbPath))
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
File.Delete(dbPath);
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Hotkeys;
|
||||
|
||||
public sealed class GlobalHotkeyService : BackgroundService
|
||||
{
|
||||
private const int HotkeyId = 0x4D41;
|
||||
private const int HotkeyIdBase = 0x4D41;
|
||||
private const int WmHotkey = 0x0312;
|
||||
private const int WmQuit = 0x0012;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
||||
private readonly MeetingRecordingCoordinator coordinator;
|
||||
private readonly ILogger<GlobalHotkeyService> logger;
|
||||
private readonly Dictionary<int, LaunchProfileHotkey> registeredHotkeys = [];
|
||||
private TaskCompletionSource? messageLoopCompletion;
|
||||
private uint messageThreadId;
|
||||
|
||||
public GlobalHotkeyService(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
ILogger<GlobalHotkeyService> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.coordinator = coordinator;
|
||||
this.logger = logger;
|
||||
}
|
||||
@@ -58,31 +59,51 @@ public sealed class GlobalHotkeyService : BackgroundService
|
||||
|
||||
private void RunMessageLoop(CancellationToken stoppingToken)
|
||||
{
|
||||
var hotkey = HotkeyDefinition.Parse(options.Hotkey.Toggle);
|
||||
var hotkeys = launchProfiles.GetHotkeys();
|
||||
messageThreadId = GetCurrentThreadId();
|
||||
using var stoppingRegistration = stoppingToken.Register(() => PostQuitToMessageThread());
|
||||
|
||||
if (!RegisterHotKey(IntPtr.Zero, HotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey))
|
||||
for (var index = 0; index < hotkeys.Count; index++)
|
||||
{
|
||||
logger.LogWarning("Could not register global hotkey {Hotkey}", options.Hotkey.Toggle);
|
||||
return;
|
||||
}
|
||||
var profileHotkey = hotkeys[index];
|
||||
var hotkey = HotkeyDefinition.Parse(profileHotkey.Hotkey);
|
||||
var hotkeyId = HotkeyIdBase + index;
|
||||
if (!RegisterHotKey(IntPtr.Zero, hotkeyId, (uint)hotkey.Modifiers, hotkey.VirtualKey))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Could not register global hotkey {Hotkey} for launch profile {LaunchProfile} action {Action}",
|
||||
profileHotkey.Hotkey,
|
||||
profileHotkey.ProfileName,
|
||||
profileHotkey.Action);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation("Registered global hotkey {Hotkey}", options.Hotkey.Toggle);
|
||||
registeredHotkeys[hotkeyId] = profileHotkey;
|
||||
logger.LogInformation(
|
||||
"Registered global hotkey {Hotkey} for launch profile {LaunchProfile} action {Action}",
|
||||
profileHotkey.Hotkey,
|
||||
profileHotkey.ProfileName,
|
||||
profileHotkey.Action);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && GetMessage(out var message, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
if (message.Message == WmHotkey)
|
||||
if (message.Message == WmHotkey && registeredHotkeys.TryGetValue((int)message.WParam, out var hotkey))
|
||||
{
|
||||
_ = Task.Run(ToggleRecordingAsync, CancellationToken.None);
|
||||
_ = Task.Run(() => HandleHotkeyAsync(hotkey), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnregisterHotKey(IntPtr.Zero, HotkeyId);
|
||||
foreach (var hotkeyId in registeredHotkeys.Keys)
|
||||
{
|
||||
UnregisterHotKey(IntPtr.Zero, hotkeyId);
|
||||
}
|
||||
|
||||
registeredHotkeys.Clear();
|
||||
messageThreadId = 0;
|
||||
}
|
||||
}
|
||||
@@ -93,12 +114,31 @@ public sealed class GlobalHotkeyService : BackgroundService
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ToggleRecordingAsync()
|
||||
private async Task HandleHotkeyAsync(LaunchProfileHotkey hotkey)
|
||||
{
|
||||
switch (hotkey.Action)
|
||||
{
|
||||
case LaunchProfileHotkeyAction.ToggleRecording:
|
||||
await ToggleRecordingAsync(hotkey.ProfileName);
|
||||
break;
|
||||
case LaunchProfileHotkeyAction.AbortRecording:
|
||||
await AbortRecordingAsync(hotkey.ProfileName);
|
||||
break;
|
||||
case LaunchProfileHotkeyAction.CaptureScreenshot:
|
||||
await CaptureScreenshotAsync(hotkey.ProfileName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleRecordingAsync(string profileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await coordinator.ToggleAsync(CancellationToken.None);
|
||||
logger.LogInformation("Hotkey toggled recording. IsRecording={IsRecording}", status.IsRecording);
|
||||
var status = await coordinator.ToggleAsync(profileName, CancellationToken.None);
|
||||
logger.LogInformation(
|
||||
"Hotkey toggled recording for launch profile {LaunchProfile}. IsRecording={IsRecording}",
|
||||
profileName,
|
||||
status.IsRecording);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -106,6 +146,38 @@ public sealed class GlobalHotkeyService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AbortRecordingAsync(string profileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await coordinator.AbortAsync(CancellationToken.None);
|
||||
logger.LogInformation(
|
||||
"Hotkey aborted recording for launch profile {LaunchProfile}. IsRecording={IsRecording}",
|
||||
profileName,
|
||||
status.IsRecording);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Hotkey abort failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CaptureScreenshotAsync(string profileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await coordinator.CaptureScreenshotAsync(profileName, CancellationToken.None);
|
||||
logger.LogInformation(
|
||||
"Hotkey captured screenshot for launch profile {LaunchProfile}: {ScreenshotPath}",
|
||||
profileName,
|
||||
result.ScreenshotPath);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Hotkey screenshot capture failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void PostQuitToMessageThread()
|
||||
{
|
||||
var threadId = messageThreadId;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MeetingAssistant.LaunchProfiles;
|
||||
|
||||
public interface ILaunchProfileOptionsProvider
|
||||
{
|
||||
LaunchProfile GetRequiredProfile(string? name);
|
||||
|
||||
IReadOnlyList<LaunchProfileHotkey> GetHotkeys();
|
||||
}
|
||||
|
||||
public sealed record LaunchProfile(string Name, MeetingAssistantOptions Options);
|
||||
|
||||
public enum LaunchProfileHotkeyAction
|
||||
{
|
||||
ToggleRecording,
|
||||
AbortRecording,
|
||||
CaptureScreenshot
|
||||
}
|
||||
|
||||
public sealed record LaunchProfileHotkey(
|
||||
string ProfileName,
|
||||
string Hotkey,
|
||||
LaunchProfileHotkeyAction Action);
|
||||
|
||||
public sealed class ConfigurationLaunchProfileOptionsProvider : ILaunchProfileOptionsProvider
|
||||
{
|
||||
public const string DefaultProfileName = "default";
|
||||
private static readonly HotkeyDescriptor[] HotkeyDescriptors =
|
||||
[
|
||||
new("Hotkey:Toggle", options => options.Hotkey.Toggle, LaunchProfileHotkeyAction.ToggleRecording),
|
||||
new("Hotkey:Abort", options => options.Hotkey.Abort, LaunchProfileHotkeyAction.AbortRecording),
|
||||
new("Screenshots:Hotkey", options => options.Screenshots.Hotkey, LaunchProfileHotkeyAction.CaptureScreenshot)
|
||||
];
|
||||
|
||||
private readonly IConfiguration configuration;
|
||||
|
||||
public ConfigurationLaunchProfileOptionsProvider(IConfiguration configuration)
|
||||
{
|
||||
this.configuration = configuration;
|
||||
}
|
||||
|
||||
public LaunchProfile GetRequiredProfile(string? name)
|
||||
{
|
||||
var profileName = NormalizeProfileName(name);
|
||||
var options = BindDefaultOptions();
|
||||
if (profileName.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new LaunchProfile(DefaultProfileName, options);
|
||||
}
|
||||
|
||||
var profileSection = GetProfilesSection().GetSection(profileName);
|
||||
if (!profileSection.Exists())
|
||||
{
|
||||
throw new KeyNotFoundException($"Launch profile '{profileName}' is not configured.");
|
||||
}
|
||||
|
||||
profileSection.Bind(options);
|
||||
ApplyArrayOverrides(profileSection, options);
|
||||
return new LaunchProfile(profileName, options);
|
||||
}
|
||||
|
||||
public IReadOnlyList<LaunchProfileHotkey> GetHotkeys()
|
||||
{
|
||||
var hotkeys = GetProfileNames()
|
||||
.SelectMany(CreateHotkeys)
|
||||
.ToList();
|
||||
var duplicate = hotkeys
|
||||
.GroupBy(hotkey => hotkey.Hotkey.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.FirstOrDefault(group => group.Count() > 1);
|
||||
if (duplicate is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Launch profile hotkey '{duplicate.Key}' is configured more than once for profiles {string.Join(", ", duplicate.Select(hotkey => $"{hotkey.ProfileName}:{hotkey.Action}"))}.");
|
||||
}
|
||||
|
||||
return hotkeys;
|
||||
}
|
||||
|
||||
private IEnumerable<LaunchProfileHotkey> CreateHotkeys(string profileName)
|
||||
{
|
||||
var profile = GetRequiredProfile(profileName);
|
||||
var isDefaultProfile = profileName.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase);
|
||||
var profileSection = isDefaultProfile
|
||||
? null
|
||||
: GetProfilesSection().GetSection(profileName);
|
||||
|
||||
foreach (var descriptor in HotkeyDescriptors)
|
||||
{
|
||||
var hotkey = descriptor.GetValue(profile.Options);
|
||||
if (ShouldRegisterHotkey(isDefaultProfile, profileSection?.GetSection(descriptor.SectionPath)) &&
|
||||
!string.IsNullOrWhiteSpace(hotkey))
|
||||
{
|
||||
yield return new LaunchProfileHotkey(
|
||||
profile.Name,
|
||||
hotkey,
|
||||
descriptor.Action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRegisterHotkey(bool isDefaultProfile, IConfigurationSection? profileHotkeySection)
|
||||
{
|
||||
return isDefaultProfile || profileHotkeySection?.Exists() == true;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> GetProfileNames()
|
||||
{
|
||||
return new[] { DefaultProfileName }
|
||||
.Concat(GetProfilesSection()
|
||||
.GetChildren()
|
||||
.Select(section => section.Key)
|
||||
.Where(key => !key.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private MeetingAssistantOptions BindDefaultOptions()
|
||||
{
|
||||
var options = new MeetingAssistantOptions();
|
||||
GetRootSection().Bind(options);
|
||||
return options;
|
||||
}
|
||||
|
||||
private IConfigurationSection GetRootSection()
|
||||
{
|
||||
return configuration.GetSection("MeetingAssistant");
|
||||
}
|
||||
|
||||
private IConfigurationSection GetProfilesSection()
|
||||
{
|
||||
return GetRootSection().GetSection("LaunchProfiles");
|
||||
}
|
||||
|
||||
private static string NormalizeProfileName(string? name)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(name) ||
|
||||
name.Equals(DefaultProfileName, StringComparison.OrdinalIgnoreCase)
|
||||
? DefaultProfileName
|
||||
: name.Trim();
|
||||
}
|
||||
|
||||
private static void ApplyArrayOverrides(
|
||||
IConfigurationSection profileSection,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
var autoDetectLanguages = ReadArrayOverride(profileSection.GetSection("AzureSpeech:AutoDetectLanguages"));
|
||||
if (autoDetectLanguages is not null)
|
||||
{
|
||||
options.AzureSpeech.AutoDetectLanguages = autoDetectLanguages;
|
||||
}
|
||||
|
||||
var funAsrChunkSize = ReadIntArrayOverride(profileSection.GetSection("FunAsr:ChunkSize"));
|
||||
if (funAsrChunkSize is not null)
|
||||
{
|
||||
options.FunAsr.ChunkSize = funAsrChunkSize;
|
||||
}
|
||||
}
|
||||
|
||||
private static string[]? ReadArrayOverride(IConfigurationSection section)
|
||||
{
|
||||
if (!section.Exists())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return section
|
||||
.GetChildren()
|
||||
.OrderBy(child => int.TryParse(child.Key, out var index) ? index : int.MaxValue)
|
||||
.Select(child => child.Value)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value!)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static int[]? ReadIntArrayOverride(IConfigurationSection section)
|
||||
{
|
||||
if (!section.Exists())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return section
|
||||
.GetChildren()
|
||||
.OrderBy(child => int.TryParse(child.Key, out var index) ? index : int.MaxValue)
|
||||
.Select(child => int.TryParse(child.Value, out var value) ? value : (int?)null)
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed record HotkeyDescriptor(
|
||||
string SectionPath,
|
||||
Func<MeetingAssistantOptions, string> GetValue,
|
||||
LaunchProfileHotkeyAction Action);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>net10.0;net10.0-windows</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -12,9 +13,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.7.0" />
|
||||
<PackageReference Include="DiffPlex" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.50.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="5.12.0" />
|
||||
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="Whisper.net" Version="1.9.0" />
|
||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="17.1.0" />
|
||||
@@ -24,6 +29,7 @@
|
||||
<Compile Remove="Hotkeys\GlobalHotkeyService.cs" />
|
||||
<Compile Remove="Recording\NaudioCaptureSource.cs" />
|
||||
<Compile Remove="MeetingNotes\OutlookClassicMeetingMetadataProvider.Windows.cs" />
|
||||
<Compile Remove="Screenshots\ActiveWindowScreenshotCapture.Windows.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -16,14 +16,74 @@ public sealed class MeetingAssistantOptions
|
||||
|
||||
public SpeakerIdentificationOptions SpeakerIdentification { get; set; } = new();
|
||||
|
||||
public AutomationOptions Automation { get; set; } = new();
|
||||
|
||||
public ScreenshotOptions Screenshots { get; set; } = new();
|
||||
|
||||
public AgentOptions Agent { get; set; } = new();
|
||||
|
||||
public ApiOptions Api { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AutomationOptions
|
||||
{
|
||||
public string? RulesPath { get; set; } = "meeting-rules.local.yaml";
|
||||
}
|
||||
|
||||
public sealed class ScreenshotOptions
|
||||
{
|
||||
public string Hotkey { get; set; } = "Ctrl+Alt+S";
|
||||
|
||||
public string AttachmentsFolder { get; set; } = "Attachments";
|
||||
|
||||
public ScreenshotOcrOptions Ocr { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ScreenshotOcrOptions
|
||||
{
|
||||
public const string DefaultPrompt = """
|
||||
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 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.
|
||||
If you can isolate the actual presentation, shared screen, or similarly relevant meeting content, return crop coordinates for only that content.
|
||||
Crop coordinates must be pixel coordinates relative to the original screenshot: x, y, width, height.
|
||||
Do not return crop coordinates for a person, webcam tile, whole app chrome, or content you cannot isolate confidently.
|
||||
End your response with exactly one fenced json block containing meeting-assistant metadata, for example:
|
||||
```json
|
||||
{ "crop": { "x": 0, "y": 0, "width": 1920, "height": 1080 } }
|
||||
```
|
||||
Use `{ "crop": null }` when no confident presentation/shared-screen crop exists.
|
||||
Do not invent information that is not visible in the screenshot.
|
||||
""";
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public string? Key { get; set; }
|
||||
|
||||
public string KeyEnv { get; set; } = "LITELLM_API_KEY";
|
||||
|
||||
public string? Model { get; set; }
|
||||
|
||||
public string Prompt { get; set; } = DefaultPrompt;
|
||||
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
public TimeSpan GetEffectiveTimeout()
|
||||
{
|
||||
return Timeout > TimeSpan.Zero ? Timeout : TimeSpan.FromMinutes(2);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HotkeyOptions
|
||||
{
|
||||
public string Toggle { get; set; } = "Ctrl+Alt+M";
|
||||
|
||||
public string Abort { get; set; } = "Ctrl+Alt+D";
|
||||
}
|
||||
|
||||
public sealed class VaultOptions
|
||||
@@ -57,6 +117,8 @@ public sealed class RecordingOptions
|
||||
|
||||
public TimeSpan BackgroundMetadataLookupTimeout { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxMetadataAttendeeImportCount { get; set; } = 30;
|
||||
|
||||
public TimeSpan OpenMeetingNoteDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
public string TemporaryRecordingsFolder { get; set; } = @"%LOCALAPPDATA%\MeetingAssistant\Recordings";
|
||||
@@ -104,7 +166,7 @@ public sealed class AzureSpeechOptions
|
||||
{
|
||||
public string Endpoint { get; set; } = "";
|
||||
|
||||
public string Region { get; set; } = "germanywestcentral";
|
||||
public string Region { get; set; } = "westeurope";
|
||||
|
||||
public string Language { get; set; } = "auto";
|
||||
|
||||
@@ -120,6 +182,8 @@ public sealed class AzureSpeechOptions
|
||||
|
||||
public bool DiarizeIntermediateResults { get; set; } = true;
|
||||
|
||||
public string? PostProcessingOption { get; set; }
|
||||
|
||||
public double PhraseListWeight { get; set; } = 1.5;
|
||||
}
|
||||
|
||||
@@ -232,6 +296,8 @@ public sealed class SpeakerIdentificationOptions
|
||||
|
||||
public TimeSpan MergeRecentIdentityAge { get; set; } = TimeSpan.FromDays(14);
|
||||
|
||||
public TimeSpan MatchTimeout { get; set; } = TimeSpan.FromMinutes(3);
|
||||
|
||||
public AzureSpeechOptions AzureSpeech { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,40 @@ public interface IMeetingArtifactStore
|
||||
{
|
||||
Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextMeetingAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task AppendAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public enum AssistantContextState
|
||||
{
|
||||
CollectingMetadata,
|
||||
Transcribing,
|
||||
SpeakerRecognition,
|
||||
Summarizing,
|
||||
|
||||
@@ -4,5 +4,13 @@ public interface IMeetingNoteStore
|
||||
{
|
||||
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
|
||||
|
||||
Task<MeetingNote> SaveAsync(
|
||||
MeetingNote note,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return SaveAsync(note, cancellationToken);
|
||||
}
|
||||
|
||||
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
|
||||
public async Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -25,7 +26,8 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
|
||||
var content = Render(
|
||||
artifacts,
|
||||
AssistantContextState.Transcribing,
|
||||
meetingNote,
|
||||
AssistantContextState.CollectingMetadata,
|
||||
agenda,
|
||||
scheduledEnd,
|
||||
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
|
||||
@@ -45,12 +47,13 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
|
||||
var body = existing.Body;
|
||||
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
||||
var metadata = existingFrontmatter.ToMeetingArtifactMetadata();
|
||||
var agenda = existingFrontmatter.Agenda ?? "";
|
||||
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
Render(artifacts, state, agenda, scheduledEnd, body),
|
||||
Render(artifacts, metadata, state, agenda, scheduledEnd, body),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Updated assistant context note {AssistantContextPath} state to {State}",
|
||||
@@ -60,6 +63,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
|
||||
public async Task UpdateAssistantContextMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -73,29 +77,92 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
Render(artifacts, state, agenda, scheduledEnd, existing.Body),
|
||||
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Updated assistant context note {AssistantContextPath} calendar metadata",
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
public async Task UpdateAssistantContextMeetingAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||
? MarkdownDocumentParser.SplitOptional(await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken))
|
||||
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
|
||||
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
||||
var state = ParseState(existingFrontmatter.State);
|
||||
var agenda = existingFrontmatter.Agenda ?? "";
|
||||
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Updated assistant context note {AssistantContextPath} meeting metadata",
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
public async Task AppendAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
var prefix = File.Exists(artifacts.AssistantContextPath) &&
|
||||
new FileInfo(artifacts.AssistantContextPath).Length > 0
|
||||
? Environment.NewLine
|
||||
: "";
|
||||
await File.AppendAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
prefix + content.TrimEnd() + Environment.NewLine,
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Appended automation context to assistant context note {AssistantContextPath}",
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
private static string Render(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
AssistantContextState state,
|
||||
string? agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
string body)
|
||||
{
|
||||
var frontmatter = new MeetingArtifactFrontmatter
|
||||
{
|
||||
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, artifacts.AssistantContextPath, "Meeting Note"),
|
||||
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, artifacts.AssistantContextPath, "Transcript"),
|
||||
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, artifacts.AssistantContextPath, "Summary"),
|
||||
State = ToYamlState(state),
|
||||
Agenda = agenda ?? "",
|
||||
ScheduledEnd = scheduledEnd
|
||||
};
|
||||
var metadata = new AssistantContextMeetingMetadata(
|
||||
meetingNote.Frontmatter.Title,
|
||||
meetingNote.Frontmatter.StartTime,
|
||||
meetingNote.Frontmatter.EndTime);
|
||||
return Render(artifacts, metadata, state, agenda, scheduledEnd, body);
|
||||
}
|
||||
|
||||
private static string Render(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextMeetingMetadata metadata,
|
||||
AssistantContextState state,
|
||||
string? agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
string body)
|
||||
{
|
||||
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||
artifacts,
|
||||
metadata.Title,
|
||||
metadata.StartTime,
|
||||
metadata.EndTime,
|
||||
artifacts.AssistantContextPath,
|
||||
ToYamlState(state));
|
||||
frontmatter.Agenda = agenda ?? "";
|
||||
frontmatter.ScheduledEnd = scheduledEnd;
|
||||
|
||||
return MeetingArtifactFrontmatterRenderer.Render(
|
||||
frontmatter,
|
||||
@@ -125,23 +192,46 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
[YamlMember(Alias = "state")]
|
||||
public string? State { get; set; }
|
||||
|
||||
[YamlMember(Alias = "title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[YamlMember(Alias = "start_time")]
|
||||
public string? StartTime { get; set; }
|
||||
|
||||
[YamlMember(Alias = "end_time")]
|
||||
public string? EndTime { get; set; }
|
||||
|
||||
[YamlMember(Alias = "agenda")]
|
||||
public string? Agenda { get; set; }
|
||||
|
||||
[YamlMember(Alias = "scheduled_end")]
|
||||
public string? ScheduledEnd { get; set; }
|
||||
|
||||
public AssistantContextMeetingMetadata ToMeetingArtifactMetadata()
|
||||
{
|
||||
return new AssistantContextMeetingMetadata(
|
||||
Title ?? "",
|
||||
ParseDateTime(StartTime),
|
||||
ParseDateTime(EndTime));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AssistantContextMeetingMetadata(
|
||||
string Title,
|
||||
DateTimeOffset? StartTime,
|
||||
DateTimeOffset? EndTime);
|
||||
|
||||
private static AssistantContextState ParseState(string? value)
|
||||
{
|
||||
return value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"transcribing" => AssistantContextState.Transcribing,
|
||||
"collecting metadata" => AssistantContextState.CollectingMetadata,
|
||||
"speaker recognition" => AssistantContextState.SpeakerRecognition,
|
||||
"summarizing" => AssistantContextState.Summarizing,
|
||||
"finished" => AssistantContextState.Finished,
|
||||
"error" => AssistantContextState.Error,
|
||||
_ => AssistantContextState.Transcribing
|
||||
_ => AssistantContextState.CollectingMetadata
|
||||
};
|
||||
}
|
||||
|
||||
@@ -149,6 +239,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AssistantContextState.CollectingMetadata => "collecting metadata",
|
||||
AssistantContextState.Transcribing => "transcribing",
|
||||
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
||||
AssistantContextState.Summarizing => "summarizing",
|
||||
|
||||
@@ -22,12 +22,20 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
}
|
||||
|
||||
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SaveAsync(note, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingNote> SaveAsync(
|
||||
MeetingNote note,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(note.Path)
|
||||
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Slugify(note.Frontmatter.Title)}.md")
|
||||
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md")
|
||||
: note.Path;
|
||||
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
|
||||
var content = Render(frontmatter, note.UserNotes);
|
||||
|
||||
@@ -10,6 +10,10 @@ public sealed class MeetingArtifactFrontmatter
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
|
||||
public List<string>? Attendees { get; set; }
|
||||
|
||||
public List<string>? Projects { get; set; }
|
||||
|
||||
public string Meeting { get; set; } = "";
|
||||
|
||||
public string Transcript { get; set; } = "";
|
||||
@@ -36,10 +40,12 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
AppendScalar(builder, "title", frontmatter.Title);
|
||||
AppendDateTime(builder, "start_time", frontmatter.StartTime);
|
||||
AppendDateTime(builder, "end_time", frontmatter.EndTime);
|
||||
AppendQuoted(builder, "meeting", frontmatter.Meeting);
|
||||
AppendQuoted(builder, "transcript", frontmatter.Transcript);
|
||||
AppendQuoted(builder, "assistant_context", frontmatter.AssistantContext);
|
||||
AppendQuoted(builder, "summary", frontmatter.Summary);
|
||||
AppendListIfNotNull(builder, "attendees", frontmatter.Attendees);
|
||||
AppendListIfNotNull(builder, "projects", frontmatter.Projects);
|
||||
AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting);
|
||||
AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript);
|
||||
AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext);
|
||||
AppendQuotedIfNotEmpty(builder, "summary", frontmatter.Summary);
|
||||
if (!string.IsNullOrWhiteSpace(frontmatter.State))
|
||||
{
|
||||
AppendScalar(builder, "state", frontmatter.State);
|
||||
@@ -73,16 +79,33 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
string title,
|
||||
string sourceNotePath,
|
||||
string? state = null)
|
||||
{
|
||||
return Create(
|
||||
artifacts,
|
||||
title,
|
||||
meetingNote.Frontmatter.StartTime,
|
||||
meetingNote.Frontmatter.EndTime,
|
||||
sourceNotePath,
|
||||
state);
|
||||
}
|
||||
|
||||
public static MeetingArtifactFrontmatter Create(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string title,
|
||||
DateTimeOffset? startTime,
|
||||
DateTimeOffset? endTime,
|
||||
string sourceNotePath,
|
||||
string? state = null)
|
||||
{
|
||||
return new MeetingArtifactFrontmatter
|
||||
{
|
||||
Title = title,
|
||||
StartTime = meetingNote.Frontmatter.StartTime,
|
||||
EndTime = meetingNote.Frontmatter.EndTime,
|
||||
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
|
||||
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
|
||||
AssistantContext = ObsidianLink.ToWikiLink(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
|
||||
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, sourceNotePath, "Summary"),
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
Meeting = LinkUnlessSelf(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
|
||||
Transcript = LinkUnlessSelf(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
|
||||
AssistantContext = LinkUnlessSelf(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
|
||||
Summary = LinkUnlessSelf(artifacts.SummaryPath, sourceNotePath, "Summary"),
|
||||
State = state
|
||||
};
|
||||
}
|
||||
@@ -108,6 +131,16 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(EscapeQuoted(value));
|
||||
}
|
||||
|
||||
private static void AppendQuotedIfNotEmpty(StringBuilder builder, string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppendQuoted(builder, key, value);
|
||||
}
|
||||
|
||||
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
|
||||
{
|
||||
builder.Append(key);
|
||||
@@ -115,6 +148,22 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")));
|
||||
}
|
||||
|
||||
private static void AppendListIfNotNull(StringBuilder builder, string key, IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append(key);
|
||||
builder.AppendLine(":");
|
||||
foreach (var value in values)
|
||||
{
|
||||
builder.Append("- ");
|
||||
builder.AppendLine(EscapeListItem(value));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendBlockScalar(StringBuilder builder, string key, string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
@@ -145,4 +194,19 @@ public static class MeetingArtifactFrontmatterRenderer
|
||||
: value;
|
||||
}
|
||||
|
||||
private static string LinkUnlessSelf(string targetPath, string sourceNotePath, string label)
|
||||
{
|
||||
return IsSamePath(targetPath, sourceNotePath)
|
||||
? ""
|
||||
: ObsidianLink.ToWikiLink(targetPath, sourceNotePath, label);
|
||||
}
|
||||
|
||||
private static bool IsSamePath(string first, string second)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(first),
|
||||
Path.GetFullPath(second),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
internal static class MeetingAttendeeNames
|
||||
{
|
||||
public static string NormalizeDisplayName(string attendee)
|
||||
{
|
||||
var trimmed = attendee.Trim();
|
||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||
}
|
||||
}
|
||||
+266
-26
@@ -1,15 +1,19 @@
|
||||
using MeetingAssistant;
|
||||
using MeetingAssistant.Hotkeys;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
|
||||
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
@@ -26,8 +30,16 @@ builder.Services.AddSingleton<ITranscriptStore, VaultTranscriptStore>();
|
||||
builder.Services.AddSingleton<IMeetingNoteStore, MarkdownMeetingNoteStore>();
|
||||
builder.Services.AddSingleton<IMeetingNoteOpener, ObsidianMeetingNoteOpener>();
|
||||
builder.Services.AddSingleton<IMeetingArtifactStore, MarkdownMeetingArtifactStore>();
|
||||
builder.Services.AddSingleton<IMeetingRunArtifactCleaner, MeetingRunArtifactCleaner>();
|
||||
builder.Services.AddSingleton<IRecordedAudioStore, TemporaryRecordedAudioStore>();
|
||||
builder.Services.AddSingleton<IDictationWordStore, MarkdownDictationWordStore>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreenshotCapture>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, UnavailableActiveWindowScreenshotCapture>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<IScreenshotOcrClient, LiteLlmScreenshotOcrClient>();
|
||||
builder.Services.AddSingleton<IMeetingScreenshotService, MeetingScreenshotService>();
|
||||
builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOptions) =>
|
||||
{
|
||||
var appOptions = services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value;
|
||||
@@ -40,10 +52,13 @@ builder.Services.AddSingleton<ISpeakerIdentityDiarizationClient, AzureSpeechSpea
|
||||
builder.Services.AddSingleton<ISpeakerIdentityMatcher, AzureSpeechSpeakerIdentityMatcher>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentificationService, SpeakerIdentityService>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentityMergeService, SpeakerIdentityMergeService>();
|
||||
builder.Services.AddSingleton<ISpeakerIdentityAttendeeCanonicalizer, SpeakerIdentityAttendeeCanonicalizer>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArtifactResolver>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
builder.Services.AddSingleton<ICommandRunner, ProcessCommandRunner>();
|
||||
builder.Services.AddSingleton<IFunAsrBackendReadinessProbe, FunAsrWebSocketBackendReadinessProbe>();
|
||||
@@ -54,6 +69,9 @@ builder.Services.AddTransient<WhisperLocalStreamingTranscriptionProvider>();
|
||||
builder.Services.AddTransient<AzureSpeechStreamingTranscriptionProvider>();
|
||||
builder.Services.AddTransient<FunAsrTranscriptFinalizer>();
|
||||
builder.Services.AddTransient<PyannoteTranscriptFinalizer>();
|
||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, AzureSpeechRecognitionPipelineBuilder>();
|
||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, FunAsrSpeechRecognitionPipelineBuilder>();
|
||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineBuilder, WhisperLocalSpeechRecognitionPipelineBuilder>();
|
||||
builder.Services.AddSingleton<ISpeechRecognitionPipelineFactory, ConfiguredSpeechRecognitionPipelineFactory>();
|
||||
builder.Services.AddSingleton<MeetingRecordingCoordinator>();
|
||||
builder.Services.AddHostedService<TemporaryRecordingCleanupHostedService>();
|
||||
@@ -72,17 +90,54 @@ app.MapGet("/health", () => Results.Ok(new
|
||||
}));
|
||||
|
||||
app.MapGet("/recording/status", (MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||
app.MapGet("/profiles/{launchProfile}/recording/status", (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator) => Results.Ok(coordinator.CurrentStatus));
|
||||
app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||
IMeetingMetadataProvider metadataProvider,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
DateTimeOffset? startedAt,
|
||||
double? timeoutSeconds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await DiagnoseOutlookMeetingAsync(
|
||||
null,
|
||||
metadataProvider,
|
||||
options.Value,
|
||||
null,
|
||||
startedAt,
|
||||
timeoutSeconds,
|
||||
cancellationToken));
|
||||
app.MapGet("/profiles/{launchProfile}/diagnostics/outlook/current-meeting", async (
|
||||
string launchProfile,
|
||||
IMeetingMetadataProvider metadataProvider,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
DateTimeOffset? startedAt,
|
||||
double? timeoutSeconds,
|
||||
CancellationToken cancellationToken) =>
|
||||
await DiagnoseOutlookMeetingAsync(
|
||||
launchProfile,
|
||||
metadataProvider,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
startedAt,
|
||||
timeoutSeconds,
|
||||
cancellationToken));
|
||||
|
||||
static async Task<IResult> DiagnoseOutlookMeetingAsync(
|
||||
string? launchProfile,
|
||||
IMeetingMetadataProvider metadataProvider,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
DateTimeOffset? startedAt,
|
||||
double? timeoutSeconds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var lookupStartedAt = startedAt ?? DateTimeOffset.Now;
|
||||
var timeout = timeoutSeconds is > 0
|
||||
? TimeSpan.FromSeconds(timeoutSeconds.Value)
|
||||
: options.Value.Recording.MetadataLookupTimeout;
|
||||
: profileOptions.Recording.MetadataLookupTimeout;
|
||||
if (metadataProvider is IMeetingMetadataDiagnosticProvider diagnostics)
|
||||
{
|
||||
return Results.Ok(await diagnostics.DiagnoseCurrentMeetingAsync(
|
||||
@@ -102,78 +157,247 @@ app.MapGet("/diagnostics/outlook/current-meeting", async (
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
metadata,
|
||||
metadata is null ? "No meeting metadata provider result was available." : null));
|
||||
});
|
||||
}
|
||||
|
||||
static MeetingAssistantOptions ResolveProfileOptions(
|
||||
string? launchProfile,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles)
|
||||
{
|
||||
if (launchProfiles is not null)
|
||||
{
|
||||
return launchProfiles.GetRequiredProfile(launchProfile).Options;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launchProfile) ||
|
||||
launchProfile.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Launch profile '{launchProfile}' was requested, but no launch profile provider is registered.");
|
||||
}
|
||||
|
||||
app.MapPost("/diagnostics/speaker-identities/merge", async (
|
||||
SpeakerIdentityMergeDiagnosticRequest request,
|
||||
ISpeakerIdentityMergeService mergeService,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await MergeSpeakerIdentitiesAsync(null, request, mergeService, options.Value, null, cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/diagnostics/speaker-identities/merge", async (
|
||||
string launchProfile,
|
||||
SpeakerIdentityMergeDiagnosticRequest request,
|
||||
ISpeakerIdentityMergeService mergeService,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await MergeSpeakerIdentitiesAsync(launchProfile, request, mergeService, options.Value, launchProfiles, cancellationToken));
|
||||
|
||||
static async Task<IResult> MergeSpeakerIdentitiesAsync(
|
||||
string? launchProfile,
|
||||
SpeakerIdentityMergeDiagnosticRequest request,
|
||||
ISpeakerIdentityMergeService mergeService,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var recentAge = request.RecentDays is > 0
|
||||
? TimeSpan.FromDays(request.RecentDays.Value)
|
||||
: options.Value.SpeakerIdentification.MergeRecentIdentityAge;
|
||||
: profileOptions.SpeakerIdentification.MergeRecentIdentityAge;
|
||||
return Results.Ok(await mergeService.MergeRecentIdentitiesAsync(recentAge, cancellationToken));
|
||||
});
|
||||
}
|
||||
app.MapPost("/diagnostics/workflow/reload", (IConfiguration configuration) =>
|
||||
Results.Ok(ReloadWorkflowConfiguration(configuration)));
|
||||
|
||||
static WorkflowConfigurationReloadResponse ReloadWorkflowConfiguration(IConfiguration configuration)
|
||||
{
|
||||
if (configuration is not IConfigurationRoot root)
|
||||
{
|
||||
throw new InvalidOperationException("Application configuration does not support reload.");
|
||||
}
|
||||
|
||||
root.Reload();
|
||||
var options = new MeetingAssistantOptions();
|
||||
configuration.GetSection("MeetingAssistant").Bind(options);
|
||||
return new WorkflowConfigurationReloadResponse(options.Automation.RulesPath);
|
||||
}
|
||||
|
||||
app.MapPost("/recording/toggle", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.ToggleAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/recording/toggle", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.ToggleAsync(launchProfile, cancellationToken)));
|
||||
app.MapPost("/recording/start", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.StartAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/recording/start", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.StartAsync(launchProfile, cancellationToken)));
|
||||
app.MapPost("/recording/stop", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.StopAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/recording/stop", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.StopAsync(cancellationToken)));
|
||||
app.MapPost("/recording/abort", async (MeetingRecordingCoordinator coordinator, CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.AbortAsync(cancellationToken)));
|
||||
app.MapPost("/profiles/{launchProfile}/recording/abort", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
CancellationToken cancellationToken) =>
|
||||
Results.Ok(await coordinator.AbortAsync(cancellationToken)));
|
||||
app.MapPost("/meetings/current/summary/run", async (
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RunCurrentSummaryAsync(
|
||||
null,
|
||||
coordinator,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/current/summary/run", async (
|
||||
string launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RunCurrentSummaryAsync(
|
||||
launchProfile,
|
||||
coordinator,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
|
||||
static async Task<IResult> RunCurrentSummaryAsync(
|
||||
string? launchProfile,
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (coordinator.CurrentArtifacts is null)
|
||||
{
|
||||
return Results.Conflict(new { error = "No meeting session has been started yet." });
|
||||
}
|
||||
|
||||
return Results.Ok(await summaryPipeline.RunAsync(coordinator.CurrentArtifacts, cancellationToken));
|
||||
});
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
return Results.Ok(await summaryPipeline.RunAsync(coordinator.CurrentArtifacts, profileOptions, cancellationToken));
|
||||
}
|
||||
|
||||
app.MapPost("/meetings/summary/retry", async (
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SummaryPath))
|
||||
{
|
||||
return Results.BadRequest(new { error = "A summary path is required." });
|
||||
}
|
||||
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(request.SummaryPath, cancellationToken);
|
||||
if (artifacts is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No meeting note links to summary '{request.SummaryPath}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, cancellationToken));
|
||||
});
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
app.MapGet("/meetings/summary/retry", async (
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
|
||||
static async Task<IResult> RetrySummaryAsync(
|
||||
string? launchProfile,
|
||||
string? summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summaryPath))
|
||||
{
|
||||
return Results.BadRequest(new { error = "A summary path is required." });
|
||||
}
|
||||
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, cancellationToken);
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken);
|
||||
if (artifacts is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, cancellationToken));
|
||||
});
|
||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, profileOptions, cancellationToken));
|
||||
}
|
||||
|
||||
app.MapPost("/asr/transcribe-file", async (
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken) =>
|
||||
await TranscribeFileAsync(null, request, diagnostics, cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/asr/transcribe-file", async (
|
||||
string launchProfile,
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken) =>
|
||||
await TranscribeFileAsync(launchProfile, request, diagnostics, cancellationToken));
|
||||
|
||||
static async Task<IResult> TranscribeFileAsync(
|
||||
string? launchProfile,
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Path))
|
||||
{
|
||||
@@ -182,7 +406,7 @@ app.MapPost("/asr/transcribe-file", async (
|
||||
|
||||
try
|
||||
{
|
||||
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, cancellationToken));
|
||||
return Results.Ok(await diagnostics.TranscribeWavAsync(request.Path, launchProfile, cancellationToken));
|
||||
}
|
||||
catch (FileNotFoundException exception)
|
||||
{
|
||||
@@ -199,11 +423,24 @@ app.MapPost("/asr/transcribe-file", async (
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "ASR backend failed while processing the WAV file.");
|
||||
}
|
||||
});
|
||||
}
|
||||
app.MapPost("/asr/diarize-file", async (
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken) =>
|
||||
await DiarizeFileAsync(null, request, diagnostics, cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/asr/diarize-file", async (
|
||||
string launchProfile,
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken) =>
|
||||
await DiarizeFileAsync(launchProfile, request, diagnostics, cancellationToken));
|
||||
|
||||
static async Task<IResult> DiarizeFileAsync(
|
||||
string? launchProfile,
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Path))
|
||||
{
|
||||
@@ -221,6 +458,7 @@ app.MapPost("/asr/diarize-file", async (
|
||||
return Results.Ok(await diagnostics.DiarizeWavAsync(
|
||||
fullPath,
|
||||
new SpeechRecognitionPipelineOptions(request.NumSpeakers),
|
||||
launchProfile,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -230,7 +468,7 @@ app.MapPost("/asr/diarize-file", async (
|
||||
statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "ASR diarization backend failed while processing the WAV file.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -250,3 +488,5 @@ public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null);
|
||||
public sealed record SummaryRetryRequest(string SummaryPath);
|
||||
|
||||
public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null);
|
||||
|
||||
public sealed record WorkflowConfigurationReloadResponse(string? RulesPath);
|
||||
|
||||
@@ -4,6 +4,13 @@ public interface IRecordedAudioStore
|
||||
{
|
||||
Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<IRecordedAudioSink> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,13 @@ public interface ITranscriptStore
|
||||
{
|
||||
Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<TranscriptSession> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CreateSessionAsync(cancellationToken);
|
||||
}
|
||||
|
||||
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken);
|
||||
|
||||
Task ReplaceAsync(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMeetingRunArtifactCleaner
|
||||
{
|
||||
Task DeleteRunArtifactsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MeetingRunArtifactCleaner : IMeetingRunArtifactCleaner
|
||||
{
|
||||
private static readonly Regex MarkdownImageLinkPattern = new("!\\[[^\\]]*\\]\\((?<path>[^)]+)\\)", RegexOptions.Compiled);
|
||||
private static readonly Regex GeneratedScreenshotFileNamePattern = new(
|
||||
@"^\d{8}-\d{6}-\d{3}-\d{6}(?:-cropped)?\.png$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private readonly ILogger<MeetingRunArtifactCleaner>? logger;
|
||||
|
||||
public MeetingRunArtifactCleaner(ILogger<MeetingRunArtifactCleaner>? logger = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task DeleteRunArtifactsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var linkedFiles = await GetLinkedScreenshotAttachmentFilesAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
options.Screenshots.AttachmentsFolder,
|
||||
cancellationToken);
|
||||
foreach (var linkedFile in linkedFiles)
|
||||
{
|
||||
await DeleteFileIfExistsAsync(linkedFile, cancellationToken);
|
||||
}
|
||||
|
||||
await DeleteFileIfExistsAsync(artifacts.SummaryPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.AssistantContextPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.TranscriptPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> GetLinkedScreenshotAttachmentFilesAsync(
|
||||
string assistantContextPath,
|
||||
string configuredAttachmentsFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var contextDirectory = Path.GetDirectoryName(Path.GetFullPath(assistantContextPath));
|
||||
if (string.IsNullOrWhiteSpace(contextDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var attachmentsFolder = ResolveAttachmentsFolder(contextDirectory, configuredAttachmentsFolder);
|
||||
return MarkdownImageLinkPattern
|
||||
.Matches(content)
|
||||
.Select(match => match.Groups["path"].Value.Trim())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(path => ResolveScreenshotAttachmentLink(contextDirectory, attachmentsFolder, path))
|
||||
.Where(path => path is not null)
|
||||
.Select(path => path!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ResolveScreenshotAttachmentLink(
|
||||
string contextDirectory,
|
||||
string attachmentsFolder,
|
||||
string linkPath)
|
||||
{
|
||||
if (Uri.TryCreate(linkPath, UriKind.Absolute, out var uri) && !uri.IsFile)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var withoutFragment = linkPath.Split('#')[0].Split('?')[0];
|
||||
var decoded = Uri.UnescapeDataString(withoutFragment);
|
||||
var fullPath = Path.IsPathRooted(decoded)
|
||||
? Path.GetFullPath(decoded)
|
||||
: Path.GetFullPath(Path.Combine(contextDirectory, decoded));
|
||||
var attachmentsRoot = Path.GetFullPath(attachmentsFolder).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
|
||||
Path.DirectorySeparatorChar;
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
return fullPath.StartsWith(attachmentsRoot, StringComparison.OrdinalIgnoreCase) &&
|
||||
GeneratedScreenshotFileNamePattern.IsMatch(fileName)
|
||||
? fullPath
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ResolveAttachmentsFolder(string contextDirectory, string configuredFolder)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(
|
||||
string.IsNullOrWhiteSpace(configuredFolder) ? "Attachments" : configuredFolder);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(Path.Combine(contextDirectory, expanded));
|
||||
}
|
||||
|
||||
private async Task DeleteFileIfExistsAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
logger?.LogInformation("Deleted aborted meeting artifact {ArtifactPath}", path);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger?.LogWarning(exception, "Could not delete aborted meeting artifact {ArtifactPath}", path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,14 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetTemporaryRecordingsFolder();
|
||||
return CreateSessionAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IRecordedAudioSink> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetTemporaryRecordingsFolder(options);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var path = Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Guid.NewGuid():N}-recording.wav");
|
||||
@@ -30,7 +37,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
||||
|
||||
public Task DeleteStaleRecordingsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetTemporaryRecordingsFolder();
|
||||
var folder = GetTemporaryRecordingsFolder(options);
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
@@ -45,7 +52,7 @@ public sealed class TemporaryRecordedAudioStore : IRecordedAudioStore
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private string GetTemporaryRecordingsFolder()
|
||||
private static string GetTemporaryRecordingsFolder(MeetingAssistantOptions options)
|
||||
{
|
||||
return VaultPath.Resolve(options.Recording.TemporaryRecordingsFolder);
|
||||
}
|
||||
|
||||
@@ -16,11 +16,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
|
||||
}
|
||||
|
||||
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await CreateSessionAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TranscriptSession> CreateSessionAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-transcript.md";
|
||||
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-transcript.md";
|
||||
var path = Path.Combine(folder, fileName);
|
||||
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
|
||||
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
public sealed class ActiveWindowScreenshotCapture : IActiveWindowScreenshotCapture
|
||||
{
|
||||
private static readonly IntPtr DpiAwarenessContextPerMonitorAwareV2 = new(-4);
|
||||
private const int DwmWindowAttributeExtendedFrameBounds = 9;
|
||||
|
||||
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var previousDpiContext = TrySetThreadDpiAwarenessContext(DpiAwarenessContextPerMonitorAwareV2);
|
||||
try
|
||||
{
|
||||
var foregroundWindow = GetForegroundWindow();
|
||||
if (foregroundWindow == IntPtr.Zero ||
|
||||
!TryGetWindowBounds(foregroundWindow, out var rect))
|
||||
{
|
||||
throw new InvalidOperationException("Could not determine the active window bounds.");
|
||||
}
|
||||
|
||||
using var bitmap = new Bitmap(rect.Width, rect.Height);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(rect.Left, rect.Top, 0, 0, new Size(rect.Width, rect.Height));
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return Task.FromResult(new ActiveWindowScreenshot(stream.ToArray(), GetWindowTitle(foregroundWindow)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (previousDpiContext != IntPtr.Zero)
|
||||
{
|
||||
TrySetThreadDpiAwarenessContext(previousDpiContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetWindowBounds(IntPtr window, out NativeRect rect)
|
||||
{
|
||||
if (DwmGetWindowAttribute(
|
||||
window,
|
||||
DwmWindowAttributeExtendedFrameBounds,
|
||||
out rect,
|
||||
Marshal.SizeOf<NativeRect>()) == 0 &&
|
||||
rect.Width > 0 &&
|
||||
rect.Height > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return GetWindowRect(window, out rect) &&
|
||||
rect.Width > 0 &&
|
||||
rect.Height > 0;
|
||||
}
|
||||
|
||||
private static IntPtr TrySetThreadDpiAwarenessContext(IntPtr dpiContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
return SetThreadDpiAwarenessContext(dpiContext);
|
||||
}
|
||||
catch (EntryPointNotFoundException)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetWindowTitle(IntPtr window)
|
||||
{
|
||||
var length = GetWindowTextLength(window);
|
||||
if (length <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var buffer = new System.Text.StringBuilder(length + 1);
|
||||
return GetWindowText(window, buffer, buffer.Capacity) > 0
|
||||
? buffer.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
|
||||
|
||||
[DllImport("dwmapi.dll", PreserveSig = true)]
|
||||
private static extern int DwmGetWindowAttribute(
|
||||
IntPtr hwnd,
|
||||
int dwAttribute,
|
||||
out NativeRect pvAttribute,
|
||||
int cbAttribute);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private readonly struct NativeRect
|
||||
{
|
||||
public readonly int Left;
|
||||
public readonly int Top;
|
||||
public readonly int Right;
|
||||
public readonly int Bottom;
|
||||
|
||||
public int Width => Right - Left;
|
||||
|
||||
public int Height => Bottom - Top;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ILogger<LiteLlmScreenshotOcrClient> logger;
|
||||
private readonly Func<HttpMessageHandler>? httpMessageHandlerFactory;
|
||||
|
||||
public LiteLlmScreenshotOcrClient(ILogger<LiteLlmScreenshotOcrClient> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
internal LiteLlmScreenshotOcrClient(
|
||||
Func<HttpMessageHandler> httpMessageHandlerFactory,
|
||||
ILogger<LiteLlmScreenshotOcrClient> logger)
|
||||
{
|
||||
this.httpMessageHandlerFactory = httpMessageHandlerFactory;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var endpoint = !string.IsNullOrWhiteSpace(options.Screenshots.Ocr.Endpoint)
|
||||
? options.Screenshots.Ocr.Endpoint
|
||||
: options.Agent.Endpoint;
|
||||
var model = !string.IsNullOrWhiteSpace(options.Screenshots.Ocr.Model)
|
||||
? options.Screenshots.Ocr.Model
|
||||
: options.Agent.Model;
|
||||
var key = ResolveApiKey(options);
|
||||
var imageBytes = await File.ReadAllBytesAsync(screenshotPath, cancellationToken);
|
||||
using var httpClient = CreateHttpClient();
|
||||
httpClient.BaseAddress = NormalizeEndpoint(new Uri(endpoint));
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
|
||||
var payload = CreatePayload(model, CreatePrompt(prompt, imageBytes), imageBytes);
|
||||
using var content = new StringContent(payload.ToJsonString(JsonOptions), Encoding.UTF8, "application/json");
|
||||
using var response = await httpClient.PostAsync("responses", content, cancellationToken);
|
||||
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Screenshot OCR request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
|
||||
}
|
||||
|
||||
var text = ParseOutputText(responseJson);
|
||||
logger.LogInformation("Screenshot OCR completed for {ScreenshotPath}", screenshotPath);
|
||||
return ParseOcrResult(text);
|
||||
}
|
||||
|
||||
private HttpClient CreateHttpClient()
|
||||
{
|
||||
return httpMessageHandlerFactory is null
|
||||
? new HttpClient()
|
||||
: new HttpClient(httpMessageHandlerFactory());
|
||||
}
|
||||
|
||||
private static JsonObject CreatePayload(string model, string prompt, byte[] imageBytes)
|
||||
{
|
||||
return new JsonObject
|
||||
{
|
||||
["model"] = model,
|
||||
["store"] = false,
|
||||
["input"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["type"] = "input_text",
|
||||
["text"] = prompt
|
||||
},
|
||||
new JsonObject
|
||||
{
|
||||
["type"] = "input_image",
|
||||
["image_url"] = "data:image/png;base64," + Convert.ToBase64String(imageBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreatePrompt(string prompt, byte[] imageBytes)
|
||||
{
|
||||
return TryReadPngDimensions(imageBytes, out var width, out var height)
|
||||
? prompt + Environment.NewLine + Environment.NewLine +
|
||||
$"Original screenshot dimensions: {width}x{height} pixels. Crop coordinates must fit within these bounds."
|
||||
: prompt;
|
||||
}
|
||||
|
||||
private static ScreenshotOcrResult ParseOcrResult(string text)
|
||||
{
|
||||
ScreenshotCropCoordinates? crop = null;
|
||||
var cleaned = JsonCodeBlockRegex().Replace(text, match =>
|
||||
{
|
||||
if (TryParseCrop(match.Groups["json"].Value, out var parsedCrop))
|
||||
{
|
||||
crop = parsedCrop;
|
||||
return "";
|
||||
}
|
||||
|
||||
return match.Value;
|
||||
}).Trim();
|
||||
return new ScreenshotOcrResult(cleaned, crop);
|
||||
}
|
||||
|
||||
private static bool TryParseCrop(string json, out ScreenshotCropCoordinates? crop)
|
||||
{
|
||||
crop = null;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (!document.RootElement.TryGetProperty("crop", out var cropElement) ||
|
||||
cropElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cropElement.ValueKind != JsonValueKind.Object ||
|
||||
!TryGetInt(cropElement, "x", out var x) ||
|
||||
!TryGetInt(cropElement, "y", out var y) ||
|
||||
!TryGetInt(cropElement, "width", out var width) ||
|
||||
!TryGetInt(cropElement, "height", out var height))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
crop = new ScreenshotCropCoordinates(x, y, width, height);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetInt(JsonElement element, string propertyName, out int value)
|
||||
{
|
||||
value = 0;
|
||||
return element.TryGetProperty(propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.Number &&
|
||||
property.TryGetInt32(out value);
|
||||
}
|
||||
|
||||
private static bool TryReadPngDimensions(byte[] bytes, out int width, out int height)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (bytes.Length < 24 ||
|
||||
bytes[0] != 0x89 ||
|
||||
bytes[1] != 0x50 ||
|
||||
bytes[2] != 0x4E ||
|
||||
bytes[3] != 0x47)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
width = ReadBigEndianInt32(bytes, 16);
|
||||
height = ReadBigEndianInt32(bytes, 20);
|
||||
return width > 0 && height > 0;
|
||||
}
|
||||
|
||||
private static int ReadBigEndianInt32(byte[] bytes, int offset)
|
||||
{
|
||||
return (bytes[offset] << 24) |
|
||||
(bytes[offset + 1] << 16) |
|
||||
(bytes[offset + 2] << 8) |
|
||||
bytes[offset + 3];
|
||||
}
|
||||
|
||||
private static string ParseOutputText(string responseJson)
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseJson);
|
||||
var root = document.RootElement;
|
||||
var parts = new List<string>();
|
||||
if (root.TryGetProperty("output_text", out var outputText) &&
|
||||
outputText.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(outputText.GetString()))
|
||||
{
|
||||
parts.Add(outputText.GetString()!);
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in output.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
if (block.TryGetProperty("type", out var type) &&
|
||||
type.GetString() == "output_text" &&
|
||||
block.TryGetProperty("text", out var text) &&
|
||||
text.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(text.GetString()))
|
||||
{
|
||||
parts.Add(text.GetString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.Count == 0
|
||||
? ""
|
||||
: string.Join(Environment.NewLine + Environment.NewLine, parts);
|
||||
}
|
||||
|
||||
private static string ResolveApiKey(MeetingAssistantOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Screenshots.Ocr.Key))
|
||||
{
|
||||
return options.Screenshots.Ocr.Key;
|
||||
}
|
||||
|
||||
var ocrKey = Environment.GetEnvironmentVariable(options.Screenshots.Ocr.KeyEnv);
|
||||
if (!string.IsNullOrWhiteSpace(ocrKey))
|
||||
{
|
||||
return ocrKey;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Agent.Key))
|
||||
{
|
||||
return options.Agent.Key;
|
||||
}
|
||||
|
||||
var agentKey = Environment.GetEnvironmentVariable(options.Agent.KeyEnv);
|
||||
if (!string.IsNullOrWhiteSpace(agentKey))
|
||||
{
|
||||
return agentKey;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No screenshot OCR API key configured. Set MeetingAssistant:Screenshots:Ocr:Key or environment variable '{options.Screenshots.Ocr.KeyEnv}'.");
|
||||
}
|
||||
|
||||
private static Uri NormalizeEndpoint(Uri endpoint)
|
||||
{
|
||||
var value = endpoint.ToString().TrimEnd('/');
|
||||
if (!value.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value += "/v1";
|
||||
}
|
||||
|
||||
return new Uri(value + "/");
|
||||
}
|
||||
|
||||
[GeneratedRegex("```json\\s*(?<json>.*?)\\s*```", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex JsonCodeBlockRegex();
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
public interface IActiveWindowScreenshotCapture
|
||||
{
|
||||
Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ActiveWindowScreenshot(byte[] PngBytes, string? WindowTitle);
|
||||
|
||||
public interface IScreenshotOcrClient
|
||||
{
|
||||
Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ScreenshotOcrResult(string Text, ScreenshotCropCoordinates? Crop);
|
||||
|
||||
public sealed record ScreenshotCropCoordinates(int X, int Y, int Width, int Height);
|
||||
|
||||
public interface IMeetingScreenshotService
|
||||
{
|
||||
Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
DateTimeOffset? meetingStartedAt,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task WaitForPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task CancelPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingScreenshotCaptureResult(
|
||||
string ScreenshotPath,
|
||||
TimeSpan MeetingTimestamp,
|
||||
bool OcrStarted);
|
||||
|
||||
public sealed class NoopMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
public static NoopMeetingScreenshotService Instance { get; } = new();
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
DateTimeOffset? meetingStartedAt,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MeetingScreenshotCaptureResult("", TimeSpan.Zero, OcrStarted: false));
|
||||
}
|
||||
|
||||
public Task WaitForPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CancelPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
private readonly IActiveWindowScreenshotCapture screenshotCapture;
|
||||
private readonly IMeetingArtifactStore artifactStore;
|
||||
private readonly IScreenshotOcrClient ocrClient;
|
||||
private readonly ILogger<MeetingScreenshotService> logger;
|
||||
private readonly ConcurrentDictionary<string, List<PendingOcrTask>> pendingOcrByContext = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> contextFileLocks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public MeetingScreenshotService(
|
||||
IActiveWindowScreenshotCapture screenshotCapture,
|
||||
IMeetingArtifactStore artifactStore,
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ILogger<MeetingScreenshotService> logger)
|
||||
{
|
||||
this.screenshotCapture = screenshotCapture;
|
||||
this.artifactStore = artifactStore;
|
||||
this.ocrClient = ocrClient;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
DateTimeOffset? meetingStartedAt,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var screenshot = await screenshotCapture.CaptureAsync(cancellationToken);
|
||||
var timestamp = CalculateMeetingTimestamp(meetingStartedAt, capturedAt);
|
||||
var screenshotPath = await SaveScreenshotAsync(
|
||||
artifacts,
|
||||
screenshot.PngBytes,
|
||||
timestamp,
|
||||
capturedAt,
|
||||
options,
|
||||
cancellationToken);
|
||||
var relativePath = ToMarkdownPath(Path.GetRelativePath(
|
||||
Path.GetDirectoryName(artifacts.AssistantContextPath)!,
|
||||
screenshotPath));
|
||||
var screenshotId = Guid.NewGuid().ToString("N");
|
||||
var ocrEnabled = options.Screenshots.Ocr.Enabled;
|
||||
await artifactStore.AppendAssistantContextAsync(
|
||||
artifacts,
|
||||
CreateScreenshotMarkdown(screenshotId, timestamp, relativePath, screenshot.WindowTitle, ocrEnabled),
|
||||
cancellationToken);
|
||||
|
||||
if (ocrEnabled)
|
||||
{
|
||||
var ocrCancellation = new CancellationTokenSource();
|
||||
TrackOcr(
|
||||
artifacts,
|
||||
ocrCancellation,
|
||||
RunOcrAsync(artifacts, screenshotPath, screenshotId, options, ocrCancellation.Token));
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Captured meeting screenshot {ScreenshotPath} at {MeetingTimestamp}",
|
||||
screenshotPath,
|
||||
FormatTimestamp(timestamp));
|
||||
return new MeetingScreenshotCaptureResult(screenshotPath, timestamp, ocrEnabled);
|
||||
}
|
||||
|
||||
public async Task WaitForPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var contextKey = NormalizeContextKey(artifacts.AssistantContextPath);
|
||||
if (!pendingOcrByContext.TryGetValue(contextKey, out var tasks))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingOcrTask[] snapshot;
|
||||
lock (tasks)
|
||||
{
|
||||
tasks.RemoveAll(task => task.Task.IsCompleted);
|
||||
snapshot = tasks.ToArray();
|
||||
}
|
||||
|
||||
if (snapshot.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(snapshot.Select(task => task.Task)).WaitAsync(timeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Timed out waiting for {Count} screenshot OCR task(s) for {AssistantContextPath}",
|
||||
snapshot.Length,
|
||||
artifacts.AssistantContextPath);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelPendingOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var contextKey = NormalizeContextKey(artifacts.AssistantContextPath);
|
||||
if (!pendingOcrByContext.TryGetValue(contextKey, out var tasks))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingOcrTask[] snapshot;
|
||||
lock (tasks)
|
||||
{
|
||||
tasks.RemoveAll(task => task.Task.IsCompleted);
|
||||
snapshot = tasks.ToArray();
|
||||
}
|
||||
|
||||
if (snapshot.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pending in snapshot)
|
||||
{
|
||||
await pending.Cancellation.CancelAsync();
|
||||
}
|
||||
|
||||
await WaitForPendingOcrAsync(artifacts, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> SaveScreenshotAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
byte[] pngBytes,
|
||||
TimeSpan timestamp,
|
||||
DateTimeOffset capturedAt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = ResolveAttachmentsFolder(artifacts.AssistantContextPath, options.Screenshots.AttachmentsFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
var path = Path.Combine(
|
||||
folder,
|
||||
$"{capturedAt:yyyyMMdd-HHmmss-fff}-{FormatTimestamp(timestamp).Replace(":", "", StringComparison.Ordinal)}.png");
|
||||
await File.WriteAllBytesAsync(path, pngBytes, cancellationToken);
|
||||
return path;
|
||||
}
|
||||
|
||||
private async Task RunOcrAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeout = options.Screenshots.Ocr.GetEffectiveTimeout();
|
||||
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutSource.CancelAfter(timeout);
|
||||
try
|
||||
{
|
||||
var result = await ocrClient.ExtractAsync(
|
||||
screenshotPath,
|
||||
string.IsNullOrWhiteSpace(options.Screenshots.Ocr.Prompt)
|
||||
? ScreenshotOcrOptions.DefaultPrompt
|
||||
: options.Screenshots.Ocr.Prompt,
|
||||
options,
|
||||
timeoutSource.Token);
|
||||
var cropMarkdown = await TryCreateCropMarkdownAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotPath,
|
||||
result.Crop,
|
||||
timeoutSource.Token);
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
cropMarkdown +
|
||||
"### OCR" + Environment.NewLine + Environment.NewLine + result.Text.Trim(),
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation("Cancelled screenshot OCR for {ScreenshotPath}", screenshotPath);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
$"_OCR timed out after {timeout:g}._",
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Screenshot OCR failed for {ScreenshotPath}", screenshotPath);
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
$"_OCR failed: {exception.Message}_",
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReplaceOcrPlaceholderAsync(
|
||||
string assistantContextPath,
|
||||
string screenshotId,
|
||||
string replacement,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileLock = contextFileLocks.GetOrAdd(
|
||||
NormalizeContextKey(assistantContextPath),
|
||||
_ => new SemaphoreSlim(1, 1));
|
||||
await fileLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var startMarker = $"<!-- screenshot-ocr:{screenshotId} -->";
|
||||
var endMarker = $"<!-- /screenshot-ocr:{screenshotId} -->";
|
||||
var startIndex = content.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
var endIndex = content.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
if (startIndex < 0 || endIndex < startIndex)
|
||||
{
|
||||
await File.AppendAllTextAsync(
|
||||
assistantContextPath,
|
||||
Environment.NewLine + replacement.TrimEnd() + Environment.NewLine,
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
endIndex += endMarker.Length;
|
||||
var updated = content[..startIndex] +
|
||||
replacement.TrimEnd() +
|
||||
content[endIndex..];
|
||||
await File.WriteAllTextAsync(assistantContextPath, updated, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
fileLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> TryCreateCropMarkdownAsync(
|
||||
string assistantContextPath,
|
||||
string screenshotPath,
|
||||
ScreenshotCropCoordinates? crop,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (crop is null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Ignoring screenshot crop coordinates for {ScreenshotPath} because image cropping is only supported on Windows",
|
||||
screenshotPath);
|
||||
return "";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var croppedPath = await SaveCroppedScreenshotAsync(screenshotPath, crop, cancellationToken);
|
||||
var relativePath = ToMarkdownPath(Path.GetRelativePath(
|
||||
Path.GetDirectoryName(assistantContextPath)!,
|
||||
croppedPath));
|
||||
return $"" +
|
||||
Environment.NewLine +
|
||||
Environment.NewLine;
|
||||
}
|
||||
catch (Exception exception) when (exception is InvalidDataException or ArgumentException or ExternalException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Ignoring invalid screenshot crop coordinates for {ScreenshotPath}",
|
||||
screenshotPath);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CA1416
|
||||
private static Task<string> SaveCroppedScreenshotAsync(
|
||||
string screenshotPath,
|
||||
ScreenshotCropCoordinates crop,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var original = new Bitmap(screenshotPath);
|
||||
ValidateCrop(crop, original.Width, original.Height);
|
||||
var cropRectangle = new Rectangle(crop.X, crop.Y, crop.Width, crop.Height);
|
||||
using var cropped = original.Clone(cropRectangle, original.PixelFormat);
|
||||
var croppedPath = Path.Combine(
|
||||
Path.GetDirectoryName(screenshotPath)!,
|
||||
$"{Path.GetFileNameWithoutExtension(screenshotPath)}-cropped.png");
|
||||
cropped.Save(croppedPath, ImageFormat.Png);
|
||||
return Task.FromResult(croppedPath);
|
||||
}
|
||||
#pragma warning restore CA1416
|
||||
|
||||
private static void ValidateCrop(ScreenshotCropCoordinates crop, int imageWidth, int imageHeight)
|
||||
{
|
||||
if (crop.X < 0 ||
|
||||
crop.Y < 0 ||
|
||||
crop.Width <= 0 ||
|
||||
crop.Height <= 0 ||
|
||||
crop.X + crop.Width > imageWidth ||
|
||||
crop.Y + crop.Height > imageHeight)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Screenshot crop {crop.X},{crop.Y},{crop.Width},{crop.Height} is outside image bounds {imageWidth}x{imageHeight}.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackOcr(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationTokenSource cancellation,
|
||||
Task task)
|
||||
{
|
||||
var contextKey = NormalizeContextKey(artifacts.AssistantContextPath);
|
||||
var tasks = pendingOcrByContext.GetOrAdd(contextKey, _ => []);
|
||||
lock (tasks)
|
||||
{
|
||||
tasks.RemoveAll(existing => existing.Task.IsCompleted);
|
||||
tasks.Add(new PendingOcrTask(task, cancellation));
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateScreenshotMarkdown(
|
||||
string screenshotId,
|
||||
TimeSpan timestamp,
|
||||
string relativePath,
|
||||
string? windowTitle,
|
||||
bool ocrEnabled)
|
||||
{
|
||||
var title = string.IsNullOrWhiteSpace(windowTitle)
|
||||
? ""
|
||||
: Environment.NewLine + $"Window: {windowTitle.Trim()}" + Environment.NewLine;
|
||||
var timestampText = FormatTimestamp(timestamp);
|
||||
var content = $"## Screenshot [{timestampText}]" +
|
||||
title +
|
||||
Environment.NewLine +
|
||||
$"";
|
||||
if (!ocrEnabled)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
return content +
|
||||
Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
$"<!-- screenshot-ocr:{screenshotId} -->" +
|
||||
Environment.NewLine +
|
||||
"_OCR pending..._" +
|
||||
Environment.NewLine +
|
||||
$"<!-- /screenshot-ocr:{screenshotId} -->";
|
||||
}
|
||||
|
||||
private static string ResolveAttachmentsFolder(string assistantContextPath, string configuredFolder)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(
|
||||
string.IsNullOrWhiteSpace(configuredFolder) ? "Attachments" : configuredFolder);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(assistantContextPath)!, expanded));
|
||||
}
|
||||
|
||||
private static TimeSpan CalculateMeetingTimestamp(DateTimeOffset? meetingStartedAt, DateTimeOffset capturedAt)
|
||||
{
|
||||
if (meetingStartedAt is null)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var elapsed = capturedAt - meetingStartedAt.Value;
|
||||
return elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed;
|
||||
}
|
||||
|
||||
private static string FormatTimestamp(TimeSpan timestamp)
|
||||
{
|
||||
return $"{(int)timestamp.TotalHours:00}:{timestamp.Minutes:00}:{timestamp.Seconds:00}";
|
||||
}
|
||||
|
||||
private static string ToMarkdownPath(string path)
|
||||
{
|
||||
return path.Replace(Path.DirectorySeparatorChar, '/')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
}
|
||||
|
||||
private static string NormalizeContextKey(string assistantContextPath)
|
||||
{
|
||||
return Path.GetFullPath(assistantContextPath);
|
||||
}
|
||||
|
||||
private sealed record PendingOcrTask(Task Task, CancellationTokenSource Cancellation);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
public sealed class UnavailableActiveWindowScreenshotCapture : IActiveWindowScreenshotCapture
|
||||
{
|
||||
public Task<ActiveWindowScreenshot> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
throw new PlatformNotSupportedException("Active-window screenshot capture is only available on Windows.");
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = await diarizationClient.DiarizeAsync(tempPath, cancellationToken);
|
||||
var segments = await DiarizeWithTimeoutAsync(tempPath, cancellationToken);
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Speaker identity matching {DiarizedSpeaker} skipped because diarization returned no segments",
|
||||
request.DiarizedSpeaker);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Speaker identity matching {DiarizedSpeaker} received {SegmentCount} diarized segment(s)",
|
||||
request.DiarizedSpeaker,
|
||||
@@ -95,6 +103,31 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizeWithTimeoutAsync(
|
||||
string wavPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchTimeout = options.MatchTimeout;
|
||||
if (matchTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
return await diarizationClient.DiarizeAsync(wavPath, cancellationToken);
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(matchTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
return await diarizationClient.DiarizeAsync(wavPath, linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Speaker identity diarization timed out after {Timeout}",
|
||||
matchTimeout);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
|
||||
{
|
||||
using var firstReader = OpenFirstReadableWave(request);
|
||||
|
||||
@@ -7,7 +7,8 @@ public sealed record SpeakerIdentificationRequest(
|
||||
string AudioPath,
|
||||
MeetingNote MeetingNote,
|
||||
IReadOnlyList<TranscriptionSegment> Segments,
|
||||
IReadOnlyList<SpeakerAudioSample>? Samples = null);
|
||||
IReadOnlyList<SpeakerAudioSample>? Samples = null,
|
||||
IReadOnlyDictionary<string, string>? KnownSpeakerMappings = null);
|
||||
|
||||
public sealed record SpeakerAudioSample(
|
||||
string Speaker,
|
||||
@@ -32,7 +33,7 @@ public sealed record SpeakerIdentityMatchRequest(
|
||||
public sealed record SpeakerIdentityMatchCandidate(
|
||||
int IdentityId,
|
||||
string? CanonicalName,
|
||||
int TranscriptionCount,
|
||||
int ReferenceCount,
|
||||
IReadOnlyList<byte[]> Snippets);
|
||||
|
||||
public sealed record SpeakerIdentityMatch(int IdentityId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
@@ -8,8 +9,6 @@ public sealed class SpeakerIdentity
|
||||
|
||||
public string? CanonicalName { get; set; }
|
||||
|
||||
public int TranscriptionCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
@@ -20,6 +19,11 @@ public sealed class SpeakerIdentity
|
||||
|
||||
public List<SpeakerSnippet> Snippets { get; set; } = [];
|
||||
|
||||
public List<SpeakerIdentityReference> References { get; set; } = [];
|
||||
|
||||
[NotMapped]
|
||||
public int ReferenceCount => References.Count;
|
||||
|
||||
public string? GetDisplayName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CanonicalName))
|
||||
@@ -35,6 +39,65 @@ public sealed class SpeakerIdentity
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SpeakerIdentityReference
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int SpeakerIdentityId { get; set; }
|
||||
|
||||
public SpeakerIdentity? SpeakerIdentity { get; set; }
|
||||
|
||||
public string MeetingNotePath { get; set; } = "";
|
||||
|
||||
public string TranscriptPath { get; set; } = "";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public static class SpeakerIdentityReferences
|
||||
{
|
||||
public static SpeakerIdentityReference Create(
|
||||
string meetingNotePath,
|
||||
string transcriptPath,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = meetingNotePath,
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static void AddIfMissing(
|
||||
SpeakerIdentity identity,
|
||||
SpeakerIdentityReference reference,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
if (IsEmpty(reference) || identity.References.Any(existing => IsSame(existing, reference)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
identity.References.Add(Create(
|
||||
reference.MeetingNotePath,
|
||||
reference.TranscriptPath,
|
||||
createdAt ?? reference.CreatedAt));
|
||||
}
|
||||
|
||||
private static bool IsEmpty(SpeakerIdentityReference reference)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(reference.MeetingNotePath) &&
|
||||
string.IsNullOrWhiteSpace(reference.TranscriptPath);
|
||||
}
|
||||
|
||||
private static bool IsSame(SpeakerIdentityReference first, SpeakerIdentityReference second)
|
||||
{
|
||||
return string.Equals(first.MeetingNotePath, second.MeetingNotePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(first.TranscriptPath, second.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SpeakerCandidateName
|
||||
{
|
||||
public int Id { get; set; }
|
||||
@@ -85,10 +148,15 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
|
||||
public DbSet<SpeakerSnippet> SpeakerSnippets => Set<SpeakerSnippet>();
|
||||
|
||||
public DbSet<SpeakerIdentityReference> SpeakerIdentityReferences => Set<SpeakerIdentityReference>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<SpeakerIdentity>(entity =>
|
||||
{
|
||||
entity.Property<int>("TranscriptionCount")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
entity.HasMany(identity => identity.CandidateNames)
|
||||
.WithOne(candidate => candidate.SpeakerIdentity)
|
||||
.HasForeignKey(candidate => candidate.SpeakerIdentityId)
|
||||
@@ -103,6 +171,11 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
.WithOne(snippet => snippet.SpeakerIdentity)
|
||||
.HasForeignKey(snippet => snippet.SpeakerIdentityId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(identity => identity.References)
|
||||
.WithOne(reference => reference.SpeakerIdentity)
|
||||
.HasForeignKey(reference => reference.SpeakerIdentityId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SpeakerCandidateName>()
|
||||
@@ -112,5 +185,9 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
modelBuilder.Entity<SpeakerAlias>()
|
||||
.HasIndex(alias => new { alias.SpeakerIdentityId, alias.Name })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<SpeakerIdentityReference>()
|
||||
.HasIndex(reference => new { reference.SpeakerIdentityId, reference.MeetingNotePath, reference.TranscriptPath })
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
public interface ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
|
||||
|
||||
public SpeakerIdentityAttendeeCanonicalizer(IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory)
|
||||
{
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attendeeEntries = attendees
|
||||
.Select(attendee => new
|
||||
{
|
||||
Raw = attendee.Trim(),
|
||||
DisplayName = NormalizeName(attendee)
|
||||
})
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Raw) && !string.IsNullOrWhiteSpace(entry.DisplayName))
|
||||
.ToList();
|
||||
if (attendeeEntries.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
|
||||
var identities = await context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.References)
|
||||
.ToListAsync(cancellationToken);
|
||||
var identitiesByAcceptedName = identities
|
||||
.OrderBy(identity => string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||
.ThenByDescending(identity => identity.ReferenceCount)
|
||||
.ThenByDescending(identity => identity.UpdatedAt)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.SelectMany(identity => GetExactAcceptedNames(identity).Select(name => new { Name = name, Identity = identity }))
|
||||
.GroupBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First().Identity, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = new List<string>();
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenIdentityIds = new HashSet<int>();
|
||||
foreach (var attendee in attendeeEntries)
|
||||
{
|
||||
if (identitiesByAcceptedName.TryGetValue(attendee.DisplayName!, out var identity))
|
||||
{
|
||||
var displayName = identity.GetDisplayName();
|
||||
if (string.IsNullOrWhiteSpace(displayName) || !seenIdentityIds.Add(identity.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenNames.Add(displayName))
|
||||
{
|
||||
result.Add(displayName);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenNames.Add(attendee.Raw))
|
||||
{
|
||||
result.Add(attendee.Raw);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetExactAcceptedNames(SpeakerIdentity identity)
|
||||
{
|
||||
return new[] { identity.CanonicalName }
|
||||
.Concat(identity.Aliases.Select(alias => alias.Name))
|
||||
.Select(NormalizeName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name!);
|
||||
}
|
||||
|
||||
private static string? NormalizeName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MeetingAttendeeNames.NormalizeDisplayName(name);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PassthroughSpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
public static PassthroughSpeakerIdentityAttendeeCanonicalizer Instance { get; } = new();
|
||||
|
||||
private PassthroughSpeakerIdentityAttendeeCanonicalizer()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var distinctAttendees = attendees
|
||||
.Select(attendee => attendee.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(distinctAttendees);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,8 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Snippets)
|
||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
||||
.Include(identity => identity.References)
|
||||
.OrderByDescending(identity => identity.References.Count)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -107,7 +108,14 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetName = target.GetDisplayName() ?? $"identity-{target.Id}";
|
||||
var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}";
|
||||
MergeIdentities(target, source);
|
||||
await SpeakerIdentityTranscriptAudit.AppendMergedAsync(
|
||||
target.References,
|
||||
targetName,
|
||||
sourceName,
|
||||
cancellationToken);
|
||||
context.SpeakerIdentities.Remove(source);
|
||||
identities.Remove(source);
|
||||
mergedPairs++;
|
||||
@@ -138,7 +146,7 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
candidates.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||
identity.Id,
|
||||
identity.GetDisplayName(),
|
||||
identity.TranscriptionCount,
|
||||
identity.ReferenceCount,
|
||||
identity.Snippets
|
||||
.OrderBy(snippet => snippet.CreatedAt)
|
||||
.Select(snippet => snippet.WavBytes)
|
||||
@@ -166,8 +174,12 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
AddAlias(target, candidate.Name);
|
||||
}
|
||||
|
||||
target.TranscriptionCount += source.TranscriptionCount;
|
||||
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))
|
||||
|
||||
@@ -27,6 +27,25 @@ internal static class SpeakerIdentitySchema
|
||||
ON "SpeakerAliases" ("SpeakerIdentityId", "Name");
|
||||
""",
|
||||
cancellationToken);
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "SpeakerIdentityReferences" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentityReferences" PRIMARY KEY AUTOINCREMENT,
|
||||
"SpeakerIdentityId" INTEGER NOT NULL,
|
||||
"MeetingNotePath" TEXT NOT NULL,
|
||||
"TranscriptPath" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_SpeakerIdentityReferences_SpeakerIdentities_SpeakerIdentityId"
|
||||
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
cancellationToken);
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SpeakerIdentityReferences_SpeakerIdentityId_MeetingNotePath_TranscriptPath"
|
||||
ON "SpeakerIdentityReferences" ("SpeakerIdentityId", "MeetingNotePath", "TranscriptPath");
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureSpeakerIdentityTimestampColumnsAsync(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -54,9 +55,31 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
|
||||
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
|
||||
var meetingReference = CreateReference(request.MeetingNote, DateTimeOffset.UtcNow);
|
||||
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
|
||||
var knownSpeakerMappings = request.KnownSpeakerMappings ??
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var knownDiarizedSpeakers = knownSpeakerMappings.Keys
|
||||
.Where(speaker => !string.IsNullOrWhiteSpace(speaker))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var alreadyIdentifiedNames = knownSpeakerMappings.Values
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var speaker in request.Segments
|
||||
.Select(segment => segment.Speaker)
|
||||
.Where(speaker => !string.IsNullOrWhiteSpace(speaker) && !IsDiarizedSpeakerLabel(speaker)))
|
||||
{
|
||||
alreadyIdentifiedNames.Add(speaker.Trim());
|
||||
}
|
||||
|
||||
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in alreadyIdentifiedNames)
|
||||
{
|
||||
matchedAcceptedNames.Add(name);
|
||||
}
|
||||
|
||||
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
|
||||
var samplesBySpeaker = request.Samples?
|
||||
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
|
||||
@@ -73,6 +96,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
.OrderBy(group => group.Min(segment => segment.Start)))
|
||||
{
|
||||
var speaker = group.Key;
|
||||
if (knownDiarizedSpeakers.Contains(speaker) || alreadyIdentifiedNames.Contains(speaker))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (speakerMappings.ContainsKey(speaker))
|
||||
{
|
||||
continue;
|
||||
@@ -90,7 +118,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = await FindMatchAsync(context, attendees, speaker, snippet, cancellationToken);
|
||||
var match = await FindMatchAsync(
|
||||
context,
|
||||
attendees,
|
||||
alreadyIdentifiedNames,
|
||||
speaker,
|
||||
snippet,
|
||||
cancellationToken);
|
||||
if (match is null)
|
||||
{
|
||||
unmatchedSpeakers.Add((speaker, snippet));
|
||||
@@ -106,7 +140,19 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||
{
|
||||
var previousCanonicalName = identity.CanonicalName;
|
||||
AddMeetingReference(identity, meetingReference);
|
||||
UpdateMatchedIdentity(identity, attendees, snippet);
|
||||
if (string.IsNullOrWhiteSpace(previousCanonicalName) &&
|
||||
!string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||
{
|
||||
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||
identity.References,
|
||||
speaker,
|
||||
identity.CanonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var acceptedName in GetAcceptedNames(identity))
|
||||
{
|
||||
matchedAcceptedNames.Add(acceptedName);
|
||||
@@ -117,6 +163,12 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
if (!string.IsNullOrWhiteSpace(speakerName))
|
||||
{
|
||||
speakerMappings[speaker] = speakerName;
|
||||
alreadyIdentifiedNames.Add(speakerName);
|
||||
foreach (var acceptedName in GetAcceptedNames(identity))
|
||||
{
|
||||
alreadyIdentifiedNames.Add(acceptedName);
|
||||
}
|
||||
|
||||
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
|
||||
speakerName,
|
||||
GetAcceptedNames(identity).ToList()));
|
||||
@@ -125,7 +177,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||
{
|
||||
await LearnUnmatchedSpeakersAsync(context, attendees, matchedAcceptedNames, unmatchedSpeakers, cancellationToken);
|
||||
await LearnUnmatchedSpeakersAsync(
|
||||
context,
|
||||
attendees,
|
||||
matchedAcceptedNames,
|
||||
unmatchedSpeakers,
|
||||
meetingReference,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
@@ -141,6 +199,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
|
||||
SpeakerIdentityDbContext context,
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlySet<string> alreadyIdentifiedNames,
|
||||
string speaker,
|
||||
byte[] snippet,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -150,7 +209,8 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
var identities = await context.SpeakerIdentities
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.Aliases)
|
||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
||||
.Include(identity => identity.References)
|
||||
.OrderByDescending(identity => identity.References.Count)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
identities = identities
|
||||
@@ -161,8 +221,9 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IsActive = identity.UpdatedAt >= activeCutoff
|
||||
})
|
||||
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
|
||||
.Where(candidate => !MatchesAcceptedNames(candidate.Identity, alreadyIdentifiedNames))
|
||||
.OrderByDescending(candidate => candidate.IsAttendee)
|
||||
.ThenByDescending(candidate => candidate.Identity.TranscriptionCount)
|
||||
.ThenByDescending(candidate => candidate.Identity.ReferenceCount)
|
||||
.ThenBy(candidate => candidate.Identity.Id)
|
||||
.Take(maxCandidates)
|
||||
.Select(candidate => candidate.Identity)
|
||||
@@ -176,7 +237,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
batch.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||
identity.Id,
|
||||
identity.CanonicalName,
|
||||
identity.TranscriptionCount,
|
||||
identity.ReferenceCount,
|
||||
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
|
||||
.ToList());
|
||||
var match = await matcher.MatchAsync(request, cancellationToken);
|
||||
@@ -189,6 +250,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool MatchesAcceptedNames(
|
||||
SpeakerIdentity identity,
|
||||
IReadOnlySet<string> names)
|
||||
{
|
||||
return GetAcceptedNames(identity).Any(names.Contains);
|
||||
}
|
||||
|
||||
private static bool MatchesAttendees(
|
||||
SpeakerIdentity identity,
|
||||
IReadOnlyList<string> attendees)
|
||||
@@ -206,6 +274,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -214,7 +283,6 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IReadOnlyList<string> attendees,
|
||||
byte[] snippet)
|
||||
{
|
||||
identity.TranscriptionCount++;
|
||||
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
|
||||
@@ -265,6 +333,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IReadOnlyList<string> attendees,
|
||||
IEnumerable<string> matchedCanonicalNames,
|
||||
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
|
||||
SpeakerIdentityReference meetingReference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var remainingCandidates = attendees
|
||||
@@ -276,11 +345,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (_, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
||||
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
context.SpeakerIdentities.Add(new SpeakerIdentity
|
||||
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CandidateNames = remainingCandidates
|
||||
@@ -293,8 +364,24 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
WavBytes = snippet,
|
||||
CreatedAt = now
|
||||
}
|
||||
],
|
||||
References =
|
||||
[
|
||||
SpeakerIdentityReferences.Create(
|
||||
meetingReference.MeetingNotePath,
|
||||
meetingReference.TranscriptPath,
|
||||
now)
|
||||
]
|
||||
});
|
||||
};
|
||||
context.SpeakerIdentities.Add(identity);
|
||||
if (!string.IsNullOrWhiteSpace(canonicalName))
|
||||
{
|
||||
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||
identity.References,
|
||||
speaker,
|
||||
canonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
@@ -366,9 +453,30 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var trimmed = attendee.Trim();
|
||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
||||
}
|
||||
|
||||
private static bool IsDiarizedSpeakerLabel(string speaker)
|
||||
{
|
||||
var normalized = speaker.Trim();
|
||||
return normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Guest", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Speaker", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static SpeakerIdentityReference CreateReference(MeetingNote meetingNote, DateTimeOffset timestamp)
|
||||
{
|
||||
return SpeakerIdentityReferences.Create(
|
||||
meetingNote.Path,
|
||||
meetingNote.Frontmatter.Transcript,
|
||||
timestamp);
|
||||
}
|
||||
|
||||
private static void AddMeetingReference(
|
||||
SpeakerIdentity identity,
|
||||
SpeakerIdentityReference reference)
|
||||
{
|
||||
SpeakerIdentityReferences.AddIfMissing(identity, reference, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private enum SpeakerIdentityProcessingMode
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
internal static class SpeakerIdentityTranscriptAudit
|
||||
{
|
||||
public static Task AppendIdentifiedAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string speakerLabel,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendAsync(
|
||||
references,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd} {speakerLabel} was identified as {name}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static Task AppendMergedAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string name1,
|
||||
string name2,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendAsync(
|
||||
references,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd} {name1} and {name2} were merged",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task AppendAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string line,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var transcriptPath in references
|
||||
.Select(reference => reference.TranscriptPath)
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!File.Exists(transcriptPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||
if (existing.Contains(line, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = existing.EndsWith(Environment.NewLine, StringComparison.Ordinal)
|
||||
? ""
|
||||
: Environment.NewLine;
|
||||
await File.AppendAllTextAsync(
|
||||
transcriptPath,
|
||||
$"{separator}{line}{Environment.NewLine}",
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed record BoundMeetingProject(string Name, string Path);
|
||||
|
||||
public sealed class BoundMeetingProjectResolver
|
||||
{
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
|
||||
public BoundMeetingProjectResolver(MeetingAssistantOptions options)
|
||||
{
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
|
||||
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Directory.Exists(ProjectsRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(ProjectsRoot)
|
||||
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
string meetingNotePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(meetingNotePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
|
||||
?? new ProjectFrontmatter();
|
||||
return (frontmatter.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class ProjectFrontmatter
|
||||
{
|
||||
[YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,12 @@ public interface IMeetingSummaryInstructionBuilder
|
||||
Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return BuildAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ namespace MeetingAssistant.Summary;
|
||||
public interface IMeetingSummaryPipeline
|
||||
{
|
||||
Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken);
|
||||
|
||||
Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return RunAsync(artifacts, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MeetingSummaryRunResult(
|
||||
|
||||
@@ -8,6 +8,14 @@ public interface IMeetingSummaryArtifactResolver
|
||||
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
|
||||
@@ -27,7 +35,15 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
string summaryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath);
|
||||
return await ResolveBySummaryPathAsync(summaryPath, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath, options);
|
||||
if (requestedSummaryPath is null)
|
||||
{
|
||||
return null;
|
||||
@@ -58,7 +74,9 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? ResolveRequestedSummaryPath(string summaryPath)
|
||||
private static string? ResolveRequestedSummaryPath(
|
||||
string summaryPath,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(summaryPath))
|
||||
{
|
||||
|
||||
@@ -10,6 +10,15 @@ public interface IMeetingSummaryFailureWriter
|
||||
MeetingSessionArtifacts artifacts,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<MeetingSummaryRunResult> WriteAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
Exception exception,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return WriteAsync(artifacts, exception, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
||||
@@ -29,21 +38,30 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
||||
MeetingSessionArtifacts artifacts,
|
||||
Exception exception,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await WriteAsync(artifacts, exception, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> WriteAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
Exception exception,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
var meetingNote = File.Exists(artifacts.MeetingNotePath)
|
||||
? await meetingNoteStore.ReadAsync(artifacts.MeetingNotePath, cancellationToken)
|
||||
: new MeetingNote("", new MeetingNoteFrontmatter(), "");
|
||||
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||
var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
MeetingArtifactFrontmatterRenderer.DefaultTitle(meetingNote, "Meeting Summary"),
|
||||
artifacts.SummaryPath);
|
||||
cancellationToken);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(
|
||||
frontmatter,
|
||||
RenderFailureMarkdown(artifacts, exception)),
|
||||
RenderFailureMarkdown(artifacts, exception, options)),
|
||||
cancellationToken);
|
||||
|
||||
return new MeetingSummaryRunResult(
|
||||
@@ -53,7 +71,10 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
|
||||
Error: exception.Message);
|
||||
}
|
||||
|
||||
private string RenderFailureMarkdown(MeetingSessionArtifacts artifacts, Exception exception)
|
||||
private static string RenderFailureMarkdown(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
Exception exception,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("# Meeting Summary");
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
internal static class MeetingSummaryFrontmatterFactory
|
||||
{
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public static async Task<MeetingArtifactFrontmatter> CreateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string title,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
title,
|
||||
artifacts.SummaryPath);
|
||||
frontmatter.Attendees = await ResolveSummaryAttendeesAsync(
|
||||
artifacts.SummaryPath,
|
||||
meetingNote,
|
||||
cancellationToken);
|
||||
frontmatter.Projects = CopyNonEmptyList(meetingNote.Frontmatter.Projects);
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
private static async Task<List<string>?> ResolveSummaryAttendeesAsync(
|
||||
string summaryPath,
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existingAttendees = await ReadExistingSummaryAttendeesAsync(summaryPath, cancellationToken);
|
||||
return existingAttendees ?? CopyNonEmptyList(meetingNote.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
private static List<string>? CopyNonEmptyList(IReadOnlyCollection<string> values)
|
||||
{
|
||||
return values.Count == 0 ? null : values.ToList();
|
||||
}
|
||||
|
||||
private static async Task<List<string>?> ReadExistingSummaryAttendeesAsync(
|
||||
string summaryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(summaryPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(summaryPath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var yaml = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter);
|
||||
return yaml?.Attendees;
|
||||
}
|
||||
|
||||
private sealed class SummaryFrontmatterYaml
|
||||
{
|
||||
[YamlMember(Alias = "attendees")]
|
||||
public List<string>? Attendees { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
@@ -18,28 +17,35 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
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.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
""";
|
||||
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
|
||||
{
|
||||
this.options = options.Value;
|
||||
projectResolver = new BoundMeetingProjectResolver(this.options);
|
||||
}
|
||||
|
||||
public async Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await BuildAsync(artifacts, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||
? DefaultInitialPrompt
|
||||
: options.Agent.InitialPrompt.Trim();
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, cancellationToken);
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||
? instructions
|
||||
: instructions.TrimEnd() + "\n\n" + projectInstructions;
|
||||
@@ -47,9 +53,10 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
|
||||
private async Task<string> BuildProjectInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, cancellationToken);
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return "";
|
||||
@@ -62,30 +69,16 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
|
||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectsRoot = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var resolver = ReferenceEquals(options, this.options)
|
||||
? projectResolver
|
||||
: new BoundMeetingProjectResolver(options);
|
||||
var projects = new List<ProjectInstructions>();
|
||||
foreach (var projectDirectory in Directory.EnumerateDirectories(projectsRoot).Order(StringComparer.OrdinalIgnoreCase))
|
||||
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
|
||||
{
|
||||
var projectName = Path.GetFileName(projectDirectory);
|
||||
if (!projectNames.Contains(projectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var agentsPath = Path.Combine(projectDirectory, "AGENTS.md");
|
||||
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
|
||||
if (!File.Exists(agentsPath))
|
||||
{
|
||||
continue;
|
||||
@@ -94,42 +87,12 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(projectName, content));
|
||||
projects.Add(new ProjectInstructions(project.Name, content));
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
string meetingNotePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(meetingNotePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
|
||||
?? new ProjectFrontmatter();
|
||||
return (frontmatter.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||
|
||||
private sealed class ProjectFrontmatter
|
||||
{
|
||||
[YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class MeetingSummaryTools
|
||||
private readonly MeetingSessionArtifacts artifacts;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly IDictationWordStore? dictationWordStore;
|
||||
private readonly SummaryAgentWriteAudit? writeAudit;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||
: this(artifacts, new MeetingAssistantOptions(), null)
|
||||
@@ -24,11 +26,14 @@ public sealed class MeetingSummaryTools
|
||||
public MeetingSummaryTools(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
IDictationWordStore? dictationWordStore = null)
|
||||
IDictationWordStore? dictationWordStore = null,
|
||||
SummaryAgentWriteAudit? writeAudit = null)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
this.options = options;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.writeAudit = writeAudit;
|
||||
projectResolver = new BoundMeetingProjectResolver(options);
|
||||
}
|
||||
|
||||
public Task<string> ReadTranscript(int? @from = null, int? to = null)
|
||||
@@ -76,7 +81,16 @@ public sealed class MeetingSummaryTools
|
||||
return "Refused: word must not be empty.";
|
||||
}
|
||||
|
||||
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
|
||||
if (writeAudit is not null)
|
||||
{
|
||||
IReadOnlyList<string>? capturedWords = null;
|
||||
await writeAudit.CaptureFileWriteAsync(
|
||||
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
|
||||
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
|
||||
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
|
||||
}
|
||||
|
||||
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
|
||||
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
||||
}
|
||||
|
||||
@@ -87,11 +101,11 @@ public sealed class MeetingSummaryTools
|
||||
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
? title
|
||||
: meetingNote.Frontmatter.Title;
|
||||
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
|
||||
var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
|
||||
artifacts.SummaryPath);
|
||||
CancellationToken.None);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.SummaryPath,
|
||||
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
|
||||
@@ -115,7 +129,7 @@ public sealed class MeetingSummaryTools
|
||||
? await File.ReadAllTextAsync(artifacts.AssistantContextPath)
|
||||
: "";
|
||||
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
|
||||
var updatedBody = ApplyLineEdit(body, content, editMode);
|
||||
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
|
||||
var updated = string.IsNullOrWhiteSpace(frontmatter)
|
||||
? updatedBody
|
||||
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
|
||||
@@ -124,6 +138,12 @@ public sealed class MeetingSummaryTools
|
||||
return artifacts.AssistantContextPath;
|
||||
}
|
||||
|
||||
private static string StripAccidentalFrontmatter(string content)
|
||||
{
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
return document.HasFrontmatter ? document.Body : content;
|
||||
}
|
||||
|
||||
public async Task<string> ListProjects()
|
||||
{
|
||||
var projects = await GetBoundProjectsAsync();
|
||||
@@ -176,7 +196,17 @@ public sealed class MeetingSummaryTools
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
|
||||
await WriteProjectFileContentAsync(target.Path, content, editMode);
|
||||
if (writeAudit is not null)
|
||||
{
|
||||
await writeAudit.CaptureFileWriteAsync(
|
||||
target.Path,
|
||||
() => WriteProjectFileContentAsync(target.Path, content, editMode));
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteProjectFileContentAsync(target.Path, content, editMode);
|
||||
}
|
||||
|
||||
return $"{target.Project.Name}/{ToToolPath(path)}";
|
||||
}
|
||||
|
||||
@@ -237,7 +267,7 @@ public sealed class MeetingSummaryTools
|
||||
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
|
||||
}
|
||||
|
||||
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
|
||||
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
|
||||
{
|
||||
var boundProjects = await GetBoundProjectsAsync();
|
||||
if (projects is null || projects.Length == 0)
|
||||
@@ -253,7 +283,7 @@ public sealed class MeetingSummaryTools
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
|
||||
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(project))
|
||||
{
|
||||
@@ -297,7 +327,7 @@ public sealed class MeetingSummaryTools
|
||||
}
|
||||
|
||||
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
|
||||
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
|
||||
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
|
||||
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
|
||||
if (projectFolder is null)
|
||||
{
|
||||
@@ -414,56 +444,19 @@ public sealed class MeetingSummaryTools
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private async Task<List<ProjectFolder>> GetBoundProjectsAsync()
|
||||
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
|
||||
{
|
||||
var projectsRoot = GetProjectsRoot();
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectNames = await ReadMeetingProjectNamesAsync();
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return Directory.EnumerateDirectories(projectsRoot)
|
||||
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
|
||||
.Where(project => projectNames.Contains(project.Name))
|
||||
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
|
||||
{
|
||||
if (!File.Exists(artifacts.MeetingNotePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
|
||||
return (note.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return projectResolver.GetBoundProjectsAsync(artifacts);
|
||||
}
|
||||
|
||||
private string GetProjectsRoot()
|
||||
{
|
||||
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
return projectResolver.ProjectsRoot;
|
||||
}
|
||||
|
||||
private static async Task<string?> RunRipgrepAsync(
|
||||
string projectsRoot,
|
||||
IReadOnlyList<ProjectFolder> projects,
|
||||
IReadOnlyList<BoundMeetingProject> projects,
|
||||
string keywords)
|
||||
{
|
||||
try
|
||||
@@ -528,7 +521,7 @@ public sealed class MeetingSummaryTools
|
||||
return string.Join('\n', matches);
|
||||
}
|
||||
|
||||
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
|
||||
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -584,9 +577,7 @@ public sealed class MeetingSummaryTools
|
||||
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed record ProjectFolder(string Name, string Path);
|
||||
|
||||
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
|
||||
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
|
||||
|
||||
private sealed record FileLineEditMode(
|
||||
FileLineEditKind Kind,
|
||||
@@ -621,12 +612,6 @@ public sealed class MeetingSummaryTools
|
||||
Insert
|
||||
}
|
||||
|
||||
private sealed class ProjectFrontmatter
|
||||
{
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
}
|
||||
|
||||
private sealed class MeetingNoteFrontmatterYaml
|
||||
{
|
||||
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
|
||||
|
||||
@@ -39,11 +39,20 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await RunAsync(artifacts, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var agentOptions = options.Agent;
|
||||
var key = ResolveApiKey(agentOptions);
|
||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore));
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, cancellationToken);
|
||||
var writeAudit = new SummaryAgentWriteAudit(artifacts);
|
||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit));
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
@@ -85,6 +94,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
@@ -97,7 +107,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
exception,
|
||||
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
|
||||
artifacts.SummaryPath);
|
||||
return await failureWriter.WriteAsync(artifacts, exception, cancellationToken);
|
||||
var result = await failureWriter.WriteAsync(artifacts, exception, options, cancellationToken);
|
||||
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using DiffPlex.DiffBuilder;
|
||||
using DiffPlex.DiffBuilder.Model;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using System.Text;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed class SummaryAgentWriteAudit
|
||||
{
|
||||
private readonly MeetingSessionArtifacts artifacts;
|
||||
private readonly List<FileChange> changes = [];
|
||||
|
||||
public SummaryAgentWriteAudit(MeetingSessionArtifacts artifacts)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
}
|
||||
|
||||
public async Task CaptureFileWriteAsync(
|
||||
string path,
|
||||
Func<Task> writeOperation,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var before = File.Exists(fullPath)
|
||||
? await File.ReadAllTextAsync(fullPath, cancellationToken)
|
||||
: "";
|
||||
await writeOperation();
|
||||
var after = File.Exists(fullPath)
|
||||
? await File.ReadAllTextAsync(fullPath, cancellationToken)
|
||||
: "";
|
||||
Capture(fullPath, before, after, cancellationToken);
|
||||
}
|
||||
|
||||
public void Capture(
|
||||
string path,
|
||||
string before,
|
||||
string after,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (IsOwnedArtifact(fullPath) || string.Equals(before, after, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var diffLines = CreateDiffLines(before, after);
|
||||
if (diffLines.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
changes.Add(new FileChange(fullPath, diffLines));
|
||||
}
|
||||
|
||||
public async Task AppendToAssistantContextAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
var existing = File.Exists(artifacts.AssistantContextPath)
|
||||
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken)
|
||||
: "";
|
||||
var builder = new StringBuilder();
|
||||
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
|
||||
{
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("## Summary Agent External File Changes");
|
||||
builder.AppendLine();
|
||||
foreach (var change in changes)
|
||||
{
|
||||
builder.AppendLine($"### {change.Path}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("```diff");
|
||||
foreach (var line in change.DiffLines)
|
||||
{
|
||||
builder.AppendLine(line);
|
||||
}
|
||||
|
||||
builder.AppendLine("```");
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
await File.AppendAllTextAsync(artifacts.AssistantContextPath, builder.ToString(), cancellationToken);
|
||||
}
|
||||
|
||||
private bool IsOwnedArtifact(string fullPath)
|
||||
{
|
||||
return IsSamePath(fullPath, artifacts.SummaryPath) ||
|
||||
IsSamePath(fullPath, artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
private static bool IsSamePath(string first, string second)
|
||||
{
|
||||
return string.Equals(
|
||||
Path.GetFullPath(first),
|
||||
Path.GetFullPath(second),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<string> CreateDiffLines(string before, string after)
|
||||
{
|
||||
var model = InlineDiffBuilder.Diff(before, after);
|
||||
return model.Lines
|
||||
.Where(line => line.Type is ChangeType.Deleted or ChangeType.Inserted)
|
||||
.Select(line => $"{(line.Type == ChangeType.Inserted ? "+" : "-")} {line.Text}")
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record FileChange(string Path, IReadOnlyList<string> DiffLines);
|
||||
}
|
||||
@@ -12,20 +12,18 @@ public sealed class AsrDiagnosticService
|
||||
this.pipelineFactory = pipelineFactory;
|
||||
}
|
||||
|
||||
public async Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
|
||||
public Task<AsrDiagnosticResult> TranscribeWavAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
throw new ArgumentException("A WAV file path is required.", nameof(path));
|
||||
}
|
||||
return TranscribeWavAsync(path, launchProfileName: null, cancellationToken);
|
||||
}
|
||||
|
||||
var fullPath = Path.GetFullPath(Environment.ExpandEnvironmentVariables(path));
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
throw new FileNotFoundException($"WAV file was not found at '{fullPath}'.", fullPath);
|
||||
}
|
||||
|
||||
await using var pipeline = pipelineFactory.Create();
|
||||
public async Task<AsrDiagnosticResult> TranscribeWavAsync(
|
||||
string path,
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = ResolveExistingWavPath(path);
|
||||
await using var pipeline = pipelineFactory.Create(launchProfileName);
|
||||
await pipeline.InitializeAsync(cancellationToken);
|
||||
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
||||
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
||||
@@ -39,7 +37,17 @@ public sealed class AsrDiagnosticService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = ResolveExistingWavPath(path);
|
||||
await using var pipeline = pipelineFactory.Create();
|
||||
return await DiarizeWavAsync(fullPath, options, launchProfileName: null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AsrDiagnosticResult> DiarizeWavAsync(
|
||||
string path,
|
||||
SpeechRecognitionPipelineOptions options,
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = ResolveExistingWavPath(path);
|
||||
await using var pipeline = pipelineFactory.Create(launchProfileName);
|
||||
await pipeline.InitializeAsync(cancellationToken);
|
||||
var liveTranscriptTask = CollectLiveTranscriptAsync(pipeline, cancellationToken);
|
||||
await WriteWavToPipelineAsync(fullPath, pipeline, paceAudio: true, cancellationToken);
|
||||
@@ -99,7 +107,9 @@ public sealed class AsrDiagnosticService
|
||||
string path,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var reader = new WaveFileReader(path);
|
||||
var reader = new WaveFileReader(path);
|
||||
try
|
||||
{
|
||||
var format = reader.WaveFormat;
|
||||
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
|
||||
{
|
||||
@@ -113,6 +123,18 @@ public sealed class AsrDiagnosticService
|
||||
{
|
||||
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
reader.Dispose();
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// NAudio can throw while disposing reader streams from async iterators; diagnostics should not fail after reading all chunks.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public sealed class AzureSpeechRecognitionPipelineBuilder :
|
||||
SpeechRecognitionPipelineBuilder,
|
||||
ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
public AzureSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||
: base(services)
|
||||
{
|
||||
}
|
||||
|
||||
public string ProviderName => "azure-speech";
|
||||
|
||||
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||
{
|
||||
return new AzureSpeechRecognitionPipeline(CreateProfiled<AzureSpeechStreamingTranscriptionProvider>(options));
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,9 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await using var enumerator = audio.GetAsyncEnumerator(cancellationToken);
|
||||
var enumerator = audio.GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!await enumerator.MoveNextAsync())
|
||||
{
|
||||
yield break;
|
||||
@@ -94,6 +96,18 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
}
|
||||
|
||||
await pumpTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await enumerator.DisposeAsync();
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Some diagnostic async enumerable sources expose DisposeAsync but throw after the stream has been read.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal ConversationTranscriber CreateTranscriber(SpeechConfig speechConfig, AudioConfig audioConfig)
|
||||
@@ -150,7 +164,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
$"Azure Speech key is not configured. Set AzureSpeech:Key or environment variable {azure.KeyEnv}.");
|
||||
}
|
||||
|
||||
var speechConfig = SpeechConfig.FromEndpoint(GetEndpoint(azure), key);
|
||||
var speechConfig = CreateSpeechConfig(azure, key);
|
||||
var autoDetectLanguages = GetAutoDetectLanguages(azure);
|
||||
if (autoDetectLanguages.Count == 0)
|
||||
{
|
||||
@@ -167,6 +181,13 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
speechConfig.SetProperty(
|
||||
PropertyId.SpeechServiceResponse_DiarizeIntermediateResults,
|
||||
azure.DiarizeIntermediateResults ? "true" : "false");
|
||||
if (!string.IsNullOrWhiteSpace(azure.PostProcessingOption))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Azure Speech post-processing option {PostProcessingOption} is configured but skipped because the live Azure backend uses ConversationTranscriber; Speech SDK post-processing is documented for SpeechRecognizer.",
|
||||
azure.PostProcessingOption.Trim());
|
||||
}
|
||||
|
||||
return speechConfig;
|
||||
}
|
||||
|
||||
@@ -185,10 +206,17 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
}
|
||||
|
||||
internal static Uri GetEndpoint(AzureSpeechOptions options)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(options.Endpoint)
|
||||
? throw new InvalidOperationException("Azure Speech endpoint must be configured.")
|
||||
: new Uri(options.Endpoint);
|
||||
}
|
||||
|
||||
internal static SpeechConfig CreateSpeechConfig(AzureSpeechOptions options, string key)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Endpoint))
|
||||
{
|
||||
return new Uri(options.Endpoint);
|
||||
return SpeechConfig.FromEndpoint(GetEndpoint(options), key);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Region))
|
||||
@@ -196,7 +224,7 @@ public sealed class AzureSpeechStreamingTranscriptionProvider : IStreamingTransc
|
||||
throw new InvalidOperationException("Azure Speech region or endpoint must be configured.");
|
||||
}
|
||||
|
||||
return new Uri($"wss://{options.Region}.stt.speech.microsoft.com/speech/universal/v2");
|
||||
return SpeechConfig.FromSubscription(key, options.Region.Trim());
|
||||
}
|
||||
|
||||
internal static string NormalizeLanguageIdMode(string? value)
|
||||
|
||||
@@ -1,29 +1,75 @@
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public sealed class ConfiguredSpeechRecognitionPipelineFactory : ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
private readonly IServiceProvider services;
|
||||
private readonly IReadOnlyDictionary<string, ISpeechRecognitionPipelineBuilder> builders;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
||||
|
||||
public ConfiguredSpeechRecognitionPipelineFactory(
|
||||
IServiceProvider services,
|
||||
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
|
||||
IOptions<MeetingAssistantOptions> options)
|
||||
: this(builders, options, launchProfiles: null)
|
||||
{
|
||||
}
|
||||
|
||||
public ConfiguredSpeechRecognitionPipelineFactory(
|
||||
IEnumerable<ISpeechRecognitionPipelineBuilder> builders,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider? launchProfiles)
|
||||
{
|
||||
this.services = services;
|
||||
this.options = options.Value;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.builders = builders
|
||||
.GroupBy(builder => builder.ProviderName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group.Count() == 1
|
||||
? group.Single()
|
||||
: throw new InvalidOperationException(
|
||||
$"Speech recognition pipeline provider '{group.Key}' is registered more than once."),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
if (this.builders.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No speech recognition pipeline builders are registered.");
|
||||
}
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create()
|
||||
{
|
||||
return options.Recording.TranscriptionProvider switch
|
||||
{
|
||||
"funasr" => ActivatorUtilities.CreateInstance<FunAsrSpeechRecognitionPipeline>(services),
|
||||
"whisper-local" => ActivatorUtilities.CreateInstance<WhisperLocalSpeechRecognitionPipeline>(services),
|
||||
"azure-speech" => ActivatorUtilities.CreateInstance<AzureSpeechRecognitionPipeline>(services),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported speech recognition pipeline '{options.Recording.TranscriptionProvider}'.")
|
||||
};
|
||||
return Create(null);
|
||||
}
|
||||
|
||||
public ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
var profileOptions = ResolveOptions(launchProfileName);
|
||||
if (!builders.TryGetValue(profileOptions.Recording.TranscriptionProvider, out var builder))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Unsupported speech recognition pipeline '{profileOptions.Recording.TranscriptionProvider}'.");
|
||||
}
|
||||
|
||||
return builder.Create(profileOptions);
|
||||
}
|
||||
|
||||
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
||||
{
|
||||
if (launchProfiles is not null)
|
||||
{
|
||||
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
||||
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return options;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"Launch profile '{launchProfileName}' was requested, but no launch profile provider is registered.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public sealed class FunAsrSpeechRecognitionPipelineBuilder :
|
||||
SpeechRecognitionPipelineBuilder,
|
||||
ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
public FunAsrSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||
: base(services)
|
||||
{
|
||||
}
|
||||
|
||||
public string ProviderName => "funasr";
|
||||
|
||||
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||
{
|
||||
var backendLifecycle = GetRequiredService<IFunAsrBackendLifecycle>();
|
||||
return new FunAsrSpeechRecognitionPipeline(
|
||||
CreateProfiled<FunAsrStreamingTranscriptionProvider>(options),
|
||||
backendLifecycle,
|
||||
CreateProfiled<FunAsrTranscriptFinalizer>(options));
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,20 @@ public interface IDictationWordStore
|
||||
{
|
||||
Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<string>> ReadWordsAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ReadWordsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<string>> AddWordAsync(
|
||||
string word,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AddWordAsync(word, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ public interface ISpeechRecognitionPipeline : IAsyncDisposable
|
||||
public interface ISpeechRecognitionPipelineFactory
|
||||
{
|
||||
ISpeechRecognitionPipeline Create();
|
||||
|
||||
ISpeechRecognitionPipeline Create(string? launchProfileName)
|
||||
{
|
||||
return Create();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SpeechRecognitionPipelineOptions(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public interface ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
string ProviderName { get; }
|
||||
|
||||
ISpeechRecognitionPipeline Create(MeetingAssistantOptions options);
|
||||
}
|
||||
@@ -13,7 +13,14 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
||||
|
||||
public async Task<IReadOnlyList<string>> ReadWordsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetPath();
|
||||
return await ReadWordsAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ReadWordsAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetPath(options);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return [];
|
||||
@@ -25,7 +32,15 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
||||
|
||||
public async Task<IReadOnlyList<string>> AddWordAsync(string word, CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetPath();
|
||||
return await AddWordAsync(word, options, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> AddWordAsync(
|
||||
string word,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = GetPath(options);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var existing = File.Exists(path)
|
||||
? await File.ReadAllLinesAsync(path, cancellationToken)
|
||||
@@ -39,7 +54,7 @@ public sealed class MarkdownDictationWordStore : IDictationWordStore
|
||||
return words;
|
||||
}
|
||||
|
||||
private string GetPath()
|
||||
private static string GetPath(MeetingAssistantOptions options)
|
||||
{
|
||||
return VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public abstract class SpeechRecognitionPipelineBuilder
|
||||
{
|
||||
private readonly IServiceProvider services;
|
||||
|
||||
protected SpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||
{
|
||||
this.services = services;
|
||||
}
|
||||
|
||||
protected T CreateProfiled<T>(MeetingAssistantOptions options)
|
||||
{
|
||||
return ActivatorUtilities.CreateInstance<T>(services, Options.Create(options));
|
||||
}
|
||||
|
||||
protected T GetRequiredService<T>()
|
||||
where T : notnull
|
||||
{
|
||||
return services.GetRequiredService<T>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
public sealed class WhisperLocalSpeechRecognitionPipelineBuilder :
|
||||
SpeechRecognitionPipelineBuilder,
|
||||
ISpeechRecognitionPipelineBuilder
|
||||
{
|
||||
public WhisperLocalSpeechRecognitionPipelineBuilder(IServiceProvider services)
|
||||
: base(services)
|
||||
{
|
||||
}
|
||||
|
||||
public string ProviderName => "whisper-local";
|
||||
|
||||
public ISpeechRecognitionPipeline Create(MeetingAssistantOptions options)
|
||||
{
|
||||
return new WhisperLocalSpeechRecognitionPipeline(
|
||||
CreateProfiled<WhisperLocalStreamingTranscriptionProvider>(options),
|
||||
CreateProfiled<PyannoteTranscriptFinalizer>(options));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IMeetingWorkflowRulesProvider
|
||||
{
|
||||
Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProvider
|
||||
{
|
||||
private readonly ILogger<FileMeetingWorkflowRulesProvider> logger;
|
||||
private readonly IDeserializer yamlDeserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
public FileMeetingWorkflowRulesProvider(ILogger<FileMeetingWorkflowRulesProvider> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MeetingWorkflowRule>> GetRulesAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Automation.RulesPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var path = ResolvePath(options.Automation.RulesPath);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
|
||||
return [];
|
||||
}
|
||||
|
||||
var yaml = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(yaml))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var rulesFile = yamlDeserializer.Deserialize<MeetingWorkflowRulesFile>(yaml)
|
||||
?? 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,438 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using NCalc;
|
||||
using RazorLight;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IMeetingWorkflowEngine
|
||||
{
|
||||
Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
public static NoopMeetingWorkflowEngine Instance { get; } = new();
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private static readonly Regex EmailAddressPattern = new(
|
||||
@"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])(?<local>[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+)@(?<domain>(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63})(?![A-Za-z0-9-])",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly string[] ParameterNames =
|
||||
[
|
||||
"meeting.attendees.count",
|
||||
"meeting.attendees",
|
||||
"meeting.projects",
|
||||
"meeting.title",
|
||||
"meeting.state",
|
||||
"event.type",
|
||||
"state.from",
|
||||
"state.to",
|
||||
"speaker.name"
|
||||
];
|
||||
|
||||
private readonly IMeetingWorkflowRulesProvider rulesProvider;
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
private readonly IMeetingArtifactStore meetingArtifactStore;
|
||||
private readonly ILogger<MeetingWorkflowEngine> logger;
|
||||
private readonly RazorLightEngine razorEngine = new RazorLightEngineBuilder()
|
||||
.UseNoProject()
|
||||
.Build();
|
||||
|
||||
public MeetingWorkflowEngine(
|
||||
IMeetingWorkflowRulesProvider rulesProvider,
|
||||
IMeetingNoteStore meetingNoteStore,
|
||||
IMeetingArtifactStore meetingArtifactStore,
|
||||
ILogger<MeetingWorkflowEngine> logger)
|
||||
{
|
||||
this.rulesProvider = rulesProvider;
|
||||
this.meetingNoteStore = meetingNoteStore;
|
||||
this.meetingArtifactStore = meetingArtifactStore;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rules = await rulesProvider.GetRulesAsync(options, cancellationToken);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var meeting = await meetingNoteStore.ReadAsync(
|
||||
workflowEvent.Artifacts.MeetingNotePath,
|
||||
cancellationToken);
|
||||
var noteChanged = false;
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
if (!MatchesTrigger(rule, workflowEvent) ||
|
||||
!EvaluateConditions(rule.If, meeting, workflowEvent))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Applying meeting workflow rule {RuleName} for event {EventType}",
|
||||
rule.Name,
|
||||
workflowEvent.Type);
|
||||
var model = CreateTemplateModel(meeting, workflowEvent);
|
||||
foreach (var step in rule.Steps)
|
||||
{
|
||||
noteChanged |= await ApplyStepAsync(
|
||||
step,
|
||||
meeting,
|
||||
workflowEvent,
|
||||
model,
|
||||
cancellationToken);
|
||||
model = CreateTemplateModel(meeting, workflowEvent);
|
||||
}
|
||||
}
|
||||
|
||||
if (noteChanged)
|
||||
{
|
||||
var saved = await meetingNoteStore.SaveAsync(meeting, options, cancellationToken);
|
||||
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
||||
workflowEvent.Artifacts,
|
||||
saved,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool MatchesTrigger(
|
||||
MeetingWorkflowRule rule,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (rule.On.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return rule.On.Any(trigger => MatchesTrigger(trigger, workflowEvent));
|
||||
}
|
||||
|
||||
private static bool MatchesTrigger(
|
||||
MeetingWorkflowTrigger trigger,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (trigger.Created is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.Created;
|
||||
}
|
||||
|
||||
if (trigger.StateTransition is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.StateTransition &&
|
||||
workflowEvent.FromState is { } from &&
|
||||
workflowEvent.ToState is { } to &&
|
||||
MeetingWorkflowStateNames.EqualsRuleName(from, trigger.StateTransition.From) &&
|
||||
MeetingWorkflowStateNames.EqualsRuleName(to, trigger.StateTransition.To);
|
||||
}
|
||||
|
||||
if (trigger.SpeakerIdentified is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.SpeakerIdentified &&
|
||||
(string.IsNullOrWhiteSpace(trigger.SpeakerIdentified.Name) ||
|
||||
string.Equals(
|
||||
trigger.SpeakerIdentified.Name.Trim(),
|
||||
workflowEvent.SpeakerName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool EvaluateConditions(
|
||||
IReadOnlyList<MeetingWorkflowCondition> conditions,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return conditions.Count == 0 ||
|
||||
conditions.All(condition => EvaluateCondition(condition, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
private bool EvaluateCondition(
|
||||
MeetingWorkflowCondition condition,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(condition.Condition))
|
||||
{
|
||||
return EvaluateExpression(condition.Condition, meeting, workflowEvent);
|
||||
}
|
||||
|
||||
if (condition.And is { Count: > 0 })
|
||||
{
|
||||
return condition.And.All(child => EvaluateCondition(child, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
if (condition.Or is { Count: > 0 })
|
||||
{
|
||||
return condition.Or.Any(child => EvaluateCondition(child, meeting, workflowEvent));
|
||||
}
|
||||
|
||||
if (condition.Not is not null)
|
||||
{
|
||||
return !EvaluateCondition(condition.Not, meeting, workflowEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EvaluateExpression(
|
||||
string expressionText,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
var expression = new Expression(PrepareExpression(expressionText));
|
||||
foreach (var (name, value) in BuildParameters(meeting, workflowEvent))
|
||||
{
|
||||
expression.Parameters[name] = value;
|
||||
}
|
||||
|
||||
expression.Functions["contains"] = args =>
|
||||
Contains(args[0].Evaluate(), Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture));
|
||||
expression.Functions["starts_with"] = args =>
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.StartsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
expression.Functions["ends_with"] = args =>
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.EndsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
return Convert.ToBoolean(expression.Evaluate(), CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyStepAsync(
|
||||
MeetingWorkflowStep step,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingWorkflowTemplateModel model,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var value = await RenderValueAsync(step.Value ?? "", model);
|
||||
switch (step.Uses.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "add_attendee":
|
||||
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
|
||||
case "remove_attendee":
|
||||
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
||||
case "add_project":
|
||||
return AddUnique(meeting.Frontmatter.Projects, value, static project => project.Trim());
|
||||
case "set_property":
|
||||
return SetProperty(meeting, step.Property ?? step.Name, value);
|
||||
case "add_context":
|
||||
await meetingArtifactStore.AppendAssistantContextAsync(
|
||||
workflowEvent.Artifacts,
|
||||
value,
|
||||
cancellationToken);
|
||||
return false;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown meeting workflow step '{step.Uses}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> RenderValueAsync(
|
||||
string template,
|
||||
MeetingWorkflowTemplateModel model)
|
||||
{
|
||||
if (!ContainsRazorTemplateSyntax(template))
|
||||
{
|
||||
return template;
|
||||
}
|
||||
|
||||
return await razorEngine.CompileRenderStringAsync(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
EscapeEmailAddressAtSigns(template),
|
||||
model);
|
||||
}
|
||||
|
||||
private static bool ContainsRazorTemplateSyntax(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var emailAtSigns = EmailAddressPattern
|
||||
.Matches(value)
|
||||
.Select(match => match.Index + match.Value.IndexOf('@', StringComparison.Ordinal))
|
||||
.ToHashSet();
|
||||
|
||||
for (var index = 0; index < value.Length; index++)
|
||||
{
|
||||
if (value[index] != '@')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 < value.Length && value[index + 1] == '@')
|
||||
{
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (emailAtSigns.Contains(index))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string EscapeEmailAddressAtSigns(string value)
|
||||
{
|
||||
if (!value.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return EmailAddressPattern.Replace(
|
||||
value,
|
||||
match => match.Value.Replace("@", "@@", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool SetProperty(
|
||||
MeetingNote meeting,
|
||||
string? property,
|
||||
string value)
|
||||
{
|
||||
var normalized = property?.Trim().ToLowerInvariant();
|
||||
if (normalized is "title" or "meeting.title")
|
||||
{
|
||||
if (string.Equals(meeting.Frontmatter.Title, value, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
meeting.Frontmatter.Title = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported meeting workflow property '{property}'.");
|
||||
}
|
||||
|
||||
private static bool AddUnique(
|
||||
List<string> values,
|
||||
string value,
|
||||
Func<string, string> normalize)
|
||||
{
|
||||
var normalized = normalize(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized) ||
|
||||
values.Any(existing => string.Equals(
|
||||
normalize(existing),
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
values.Add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveValue(List<string> values, string value)
|
||||
{
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
|
||||
return values.RemoveAll(existing =>
|
||||
string.Equals(
|
||||
MeetingAttendeeNames.NormalizeDisplayName(existing),
|
||||
normalized,
|
||||
StringComparison.OrdinalIgnoreCase)) > 0;
|
||||
}
|
||||
|
||||
private static bool Contains(object? haystack, string? needle)
|
||||
{
|
||||
if (string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (haystack is IEnumerable enumerable and not string)
|
||||
{
|
||||
return enumerable
|
||||
.Cast<object?>()
|
||||
.Select(item => Convert.ToString(item, CultureInfo.InvariantCulture))
|
||||
.Any(item => string.Equals(item, needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Convert.ToString(haystack, CultureInfo.InvariantCulture)?.Contains(
|
||||
needle,
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private static string PrepareExpression(string expression)
|
||||
{
|
||||
var prepared = expression;
|
||||
foreach (var parameter in ParameterNames.OrderByDescending(name => name.Length))
|
||||
{
|
||||
prepared = Regex.Replace(
|
||||
prepared,
|
||||
$@"(?<![\[\w.]){Regex.Escape(parameter)}(?![\]\w.])",
|
||||
$"[{parameter}]",
|
||||
RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> BuildParameters(
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["meeting.attendees.count"] = meeting.Frontmatter.Attendees.Count,
|
||||
["meeting.attendees"] = meeting.Frontmatter.Attendees,
|
||||
["meeting.projects"] = meeting.Frontmatter.Projects,
|
||||
["meeting.title"] = meeting.Frontmatter.Title,
|
||||
["meeting.state"] = workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : "",
|
||||
["event.type"] = workflowEvent.Type.ToString(),
|
||||
["state.from"] = workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : "",
|
||||
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
|
||||
["speaker.name"] = workflowEvent.SpeakerName
|
||||
};
|
||||
}
|
||||
|
||||
private static MeetingWorkflowTemplateModel CreateTemplateModel(
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowEvent workflowEvent)
|
||||
{
|
||||
return new MeetingWorkflowTemplateModel(
|
||||
new MeetingWorkflowMeetingModel(
|
||||
meeting.Frontmatter.Title,
|
||||
meeting.Frontmatter.Attendees,
|
||||
meeting.Frontmatter.Projects,
|
||||
workflowEvent.ToState is { } state ? MeetingWorkflowStateNames.ToRuleName(state) : ""),
|
||||
new MeetingWorkflowEventModel(
|
||||
workflowEvent.Type.ToString(),
|
||||
workflowEvent.FromState is { } from ? MeetingWorkflowStateNames.ToRuleName(from) : null,
|
||||
workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : null),
|
||||
string.IsNullOrWhiteSpace(workflowEvent.SpeakerName)
|
||||
? null
|
||||
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public enum MeetingWorkflowEventType
|
||||
{
|
||||
Created,
|
||||
StateTransition,
|
||||
SpeakerIdentified
|
||||
}
|
||||
|
||||
public sealed record MeetingWorkflowEvent(
|
||||
MeetingWorkflowEventType Type,
|
||||
MeetingSessionArtifacts Artifacts,
|
||||
AssistantContextState? FromState = null,
|
||||
AssistantContextState? ToState = null,
|
||||
string? SpeakerName = null)
|
||||
{
|
||||
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.Created, artifacts);
|
||||
}
|
||||
|
||||
public static MeetingWorkflowEvent StateTransition(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState from,
|
||||
AssistantContextState to)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.StateTransition, artifacts, from, to);
|
||||
}
|
||||
|
||||
public static MeetingWorkflowEvent SpeakerIdentified(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string speakerName)
|
||||
{
|
||||
return new MeetingWorkflowEvent(MeetingWorkflowEventType.SpeakerIdentified, artifacts, SpeakerName: speakerName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MeetingWorkflowRulesFile
|
||||
{
|
||||
[YamlMember(Alias = "rules")]
|
||||
public List<MeetingWorkflowRule> Rules { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowRule
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[YamlMember(Alias = "on")]
|
||||
public List<MeetingWorkflowTrigger> On { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "if")]
|
||||
public List<MeetingWorkflowCondition> If { get; set; } = [];
|
||||
|
||||
[YamlMember(Alias = "steps")]
|
||||
public List<MeetingWorkflowStep> Steps { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowTrigger
|
||||
{
|
||||
[YamlMember(Alias = "created")]
|
||||
public object? Created { get; set; }
|
||||
|
||||
[YamlMember(Alias = "state_transition")]
|
||||
public MeetingWorkflowStateTransitionTrigger? StateTransition { get; set; }
|
||||
|
||||
[YamlMember(Alias = "speaker_identified")]
|
||||
public MeetingWorkflowSpeakerIdentifiedTrigger? SpeakerIdentified { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowStateTransitionTrigger
|
||||
{
|
||||
[YamlMember(Alias = "from")]
|
||||
public string? From { get; set; }
|
||||
|
||||
[YamlMember(Alias = "to")]
|
||||
public string? To { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowSpeakerIdentifiedTrigger
|
||||
{
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowCondition
|
||||
{
|
||||
[YamlMember(Alias = "condition")]
|
||||
public string? Condition { get; set; }
|
||||
|
||||
[YamlMember(Alias = "and")]
|
||||
public List<MeetingWorkflowCondition>? And { get; set; }
|
||||
|
||||
[YamlMember(Alias = "or")]
|
||||
public List<MeetingWorkflowCondition>? Or { get; set; }
|
||||
|
||||
[YamlMember(Alias = "not")]
|
||||
public MeetingWorkflowCondition? Not { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowStep
|
||||
{
|
||||
[YamlMember(Alias = "uses")]
|
||||
public string Uses { get; set; } = "";
|
||||
|
||||
[YamlMember(Alias = "property")]
|
||||
public string? Property { get; set; }
|
||||
|
||||
[YamlMember(Alias = "name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[YamlMember(Alias = "value")]
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed record MeetingWorkflowTemplateModel(
|
||||
MeetingWorkflowMeetingModel Meeting,
|
||||
MeetingWorkflowEventModel Event,
|
||||
MeetingWorkflowSpeakerModel? Speaker);
|
||||
|
||||
public sealed record MeetingWorkflowMeetingModel(
|
||||
string Title,
|
||||
IReadOnlyList<string> Attendees,
|
||||
IReadOnlyList<string> Projects,
|
||||
string State);
|
||||
|
||||
public sealed record MeetingWorkflowEventModel(
|
||||
string Type,
|
||||
string? From,
|
||||
string? To);
|
||||
|
||||
public sealed record MeetingWorkflowSpeakerModel(string Name);
|
||||
|
||||
internal static class MeetingWorkflowStateNames
|
||||
{
|
||||
public static string ToRuleName(AssistantContextState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AssistantContextState.CollectingMetadata => "collecting metadata",
|
||||
AssistantContextState.Transcribing => "transcribing",
|
||||
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
||||
AssistantContextState.Summarizing => "summarizing",
|
||||
AssistantContextState.Finished => "finished",
|
||||
AssistantContextState.Error => "error",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
public static bool EqualsRuleName(AssistantContextState state, string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ||
|
||||
string.Equals(ToRuleName(state), value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"MeetingAssistant": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+M"
|
||||
"Toggle": "Ctrl+Alt+M",
|
||||
"Abort": "Ctrl+Alt+D"
|
||||
},
|
||||
"Vault": {
|
||||
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
|
||||
@@ -19,6 +20,7 @@
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
"MetadataLookupTimeout": "00:00:10",
|
||||
"BackgroundMetadataLookupTimeout": "00:01:00",
|
||||
"MaxMetadataAttendeeImportCount": 30,
|
||||
"OpenMeetingNoteDelay": "00:00:01",
|
||||
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
|
||||
},
|
||||
@@ -80,16 +82,16 @@
|
||||
}
|
||||
},
|
||||
"AzureSpeech": {
|
||||
"Endpoint": "wss://germanywestcentral.stt.speech.microsoft.com/speech/universal/v2",
|
||||
"Region": "germanywestcentral",
|
||||
"Endpoint": "",
|
||||
"Region": "westeurope",
|
||||
"Language": "de-DE",
|
||||
"AutoDetectLanguages": [
|
||||
"de-DE"
|
||||
],
|
||||
"LanguageIdMode": "Continuous",
|
||||
"KeyEnv": "AZURE_SPEECH_KEY",
|
||||
"RecognitionStopTimeout": "00:00:10",
|
||||
"DiarizeIntermediateResults": true,
|
||||
"PostProcessingOption": "",
|
||||
"PhraseListWeight": 1.5
|
||||
},
|
||||
"SpeakerIdentification": {
|
||||
@@ -103,7 +105,22 @@
|
||||
"MaxSnippetsPerSpeaker": 3,
|
||||
"SilenceBetweenSnippetsSeconds": 1,
|
||||
"LiveSampleBufferDuration": "00:10:00",
|
||||
"MergeRecentIdentityAge": "14.00:00:00"
|
||||
"MergeRecentIdentityAge": "14.00:00:00",
|
||||
"MatchTimeout": "00:03:00"
|
||||
},
|
||||
"Automation": {
|
||||
"RulesPath": "C:\\Manuel\\meeting-assistant\\meeting-rules.local.yaml"
|
||||
},
|
||||
"Screenshots": {
|
||||
"Hotkey": "Ctrl+Alt+S",
|
||||
"AttachmentsFolder": "Attachments",
|
||||
"Ocr": {
|
||||
"Enabled": true,
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "",
|
||||
"Timeout": "00:02:00"
|
||||
}
|
||||
},
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
@@ -117,11 +134,23 @@
|
||||
"MaxOutputTokens": 8192,
|
||||
"EnableCompaction": true,
|
||||
"CompactionRemainingRatio": 0.1,
|
||||
"ResponsesCompactPath": "responses/compact",
|
||||
"InitialPrompt": "You are the Meeting Assistant summary agent.\n\nUse the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.\nAll 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.\nThen 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.\nUse read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.\nUse 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.\nUse 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.\nAfter writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.\nUse list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.\nThe summary note should contain concise sections for summary, decisions, open questions, and next steps.\nKeep the output grounded in the source material and explicitly say when a section has no known items."
|
||||
"ResponsesCompactPath": "responses/compact"
|
||||
},
|
||||
"Api": {
|
||||
"PublicBaseUrl": "http://localhost:5090"
|
||||
},
|
||||
"LaunchProfiles": {
|
||||
"english": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+E"
|
||||
},
|
||||
"AzureSpeech": {
|
||||
"Language": "en-US",
|
||||
"AutoDetectLanguages": [
|
||||
"en-US"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Logging": {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
- abort and discard an active recording through a configurable global hotkey
|
||||
- capture active-window screenshots through a configurable global hotkey
|
||||
- capture microphone input and computer output into one transcription stream
|
||||
- transcribe meetings with speaker attribution
|
||||
- use a configurable speech recognition pipeline, with local Whisper as the first version 1 fallback
|
||||
@@ -67,8 +69,10 @@ GET /recording/status
|
||||
POST /recording/toggle
|
||||
POST /recording/start
|
||||
POST /recording/stop
|
||||
POST /recording/abort
|
||||
POST /asr/transcribe-file
|
||||
POST /asr/diarize-file
|
||||
POST /diagnostics/workflow/reload
|
||||
POST /meetings/current/summary/run
|
||||
POST /meetings/summary/retry
|
||||
GET /meetings/summary/retry?summaryPath=...
|
||||
@@ -82,7 +86,8 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
{
|
||||
"MeetingAssistant": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+M"
|
||||
"Toggle": "Ctrl+Alt+M",
|
||||
"Abort": "Ctrl+Alt+D"
|
||||
},
|
||||
"Vault": {
|
||||
"TranscriptsFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Meetings\\Transcripts",
|
||||
@@ -97,6 +102,7 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"SampleRate": 16000,
|
||||
"Channels": 1,
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
"MaxMetadataAttendeeImportCount": 30,
|
||||
"TemporaryRecordingsFolder": "%LOCALAPPDATA%\\MeetingAssistant\\Recordings"
|
||||
},
|
||||
"FunAsr": {
|
||||
@@ -160,6 +166,21 @@ Meeting Assistant uses normal .NET configuration under the `MeetingAssistant` se
|
||||
"RecognitionStopTimeout": "00:00:10",
|
||||
"DiarizeIntermediateResults": true
|
||||
},
|
||||
"Automation": {
|
||||
"RulesPath": "meeting-rules.local.yaml"
|
||||
},
|
||||
"Screenshots": {
|
||||
"Hotkey": "Ctrl+Alt+S",
|
||||
"AttachmentsFolder": "Attachments",
|
||||
"Ocr": {
|
||||
"Enabled": false,
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "",
|
||||
"Timeout": "00:02:00",
|
||||
"Prompt": "This screenshot was captured during a meeting..."
|
||||
}
|
||||
},
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
@@ -201,12 +222,26 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as recording, final speaker recognition, and summary generation progress.
|
||||
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
|
||||
|
||||
Assistant context notes keep their artifact links in frontmatter and expose a lifecycle `state`: `collecting metadata`, `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`. Meeting Assistant updates this state as metadata lookup, recording, final speaker recognition, and summary generation progress.
|
||||
|
||||
`ProjectsFolder` contains project knowledge. Each direct subfolder is treated as one project, and a meeting binds projects by listing those subfolder names in the meeting note `projects` frontmatter.
|
||||
|
||||
`Automation:RulesPath` points to an optional local YAML rules file. The default `meeting-rules.local.yaml` is ignored by git. Rules can trigger on meeting creation, assistant-context state transitions, or identified speakers; conditions are evaluated with NCalc-style expressions and step values can use Razor syntax against `Model.Meeting`, `Model.Event`, and `Model.Speaker`. The initial supported steps are `add_attendee`, `remove_attendee`, `set_property`, `add_context`, and `add_project`.
|
||||
|
||||
See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported variables, examples, and extension notes.
|
||||
|
||||
`POST /diagnostics/workflow/reload` reloads application configuration so workflow automation settings such as `Automation:RulesPath` can be changed without restarting the application. A meeting run that already captured its options keeps using those options.
|
||||
|
||||
`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`.
|
||||
|
||||
`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.
|
||||
|
||||
`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.
|
||||
@@ -225,6 +260,8 @@ Failure summary notes include a clickable retry link using `Api:PublicBaseUrl`,
|
||||
|
||||
The summary agent currently has meeting-scoped tools to read the transcript, full meeting note including frontmatter, assistant context, user notes, glossary, and project files. Every read tool that returns file-like content can read the whole content or a clamped inclusive 1-based line range with `from` and `to`, so the agent can inspect large transcripts incrementally. The assistant context note is the agent's notebook and user-visible context window; the agent can write internal notes, discovered context, missing tool requests, suggested improvements, or follow-up task ideas there using line-based overwrite, replace, and insert modes. Project lookup and search tools are constrained to the projects listed in the meeting note frontmatter:
|
||||
|
||||
When assistant context contains cropped screenshot links produced by OCR, the summary instructions tell the agent to include only the most relevant cropped screenshots in the summary and markdown-link them near the related summary text.
|
||||
|
||||
- `read_meetingnote`
|
||||
- `list_projects`
|
||||
- `list_projectfiles`
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Meeting Workflow Engine
|
||||
|
||||
Meeting Assistant has a small local workflow engine for meeting-specific automation. It is intended for rules that are too personal or environment-specific to hard-code, such as adding default attendees, cleaning meeting titles, binding projects, or adding context notes when known speakers are detected.
|
||||
|
||||
The workflow engine is deliberately narrow. It runs from a local YAML file, evaluates rules against the current meeting note read from disk, and applies a small set of meeting-safe mutations.
|
||||
|
||||
## Configuration
|
||||
|
||||
The rules file path is configured through `MeetingAssistant:Automation:RulesPath`.
|
||||
|
||||
```json
|
||||
{
|
||||
"MeetingAssistant": {
|
||||
"Automation": {
|
||||
"RulesPath": "meeting-rules.local.yaml"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`meeting-rules.local.yaml` is the default local rules file and is ignored by git. The path may be absolute, relative to the process working directory, or use environment variables.
|
||||
|
||||
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
|
||||
|
||||
`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
|
||||
{
|
||||
"rulesPath": "meeting-rules.local.yaml"
|
||||
}
|
||||
```
|
||||
|
||||
The YAML rules file itself is read for every workflow event, so changing the contents of the same rules file does not require this endpoint. Use the endpoint when the application configuration changes, for example when pointing `RulesPath` to a different YAML file. A meeting run that already captured its options may continue with those options; future runs and future configuration reads use the reloaded configuration.
|
||||
|
||||
## Execution Model
|
||||
|
||||
The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow event:
|
||||
|
||||
- `created`: after the meeting note and assistant context artifact are created.
|
||||
- `state_transition`: after the assistant context state moves forward.
|
||||
- `speaker_identified`: when live or final speaker identification reports a display name.
|
||||
|
||||
For every event, the engine:
|
||||
|
||||
1. Loads the current rules file.
|
||||
2. Reads the latest meeting note from disk.
|
||||
3. Evaluates every matching rule in file order.
|
||||
4. Applies each matching rule's steps in order.
|
||||
5. Rebuilds the template model after every step, so later steps can see earlier mutations.
|
||||
6. Saves the meeting note once if any meeting-note step changed it.
|
||||
7. Updates assistant-context frontmatter links/title from the saved meeting note after note changes.
|
||||
|
||||
Rules are best-effort automation. Invalid expressions, unknown steps, or unsupported properties currently fail the workflow event and should be covered by tests before the engine is broadened.
|
||||
|
||||
## YAML Shape
|
||||
|
||||
The top-level YAML document contains a `rules` array.
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: add-default-attendee
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: meeting.attendees.count = 0
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
```
|
||||
|
||||
Each rule has:
|
||||
|
||||
- `name`: log-friendly rule name.
|
||||
- `on`: one or more triggers. At least one must match.
|
||||
- `if`: optional list of conditions. All top-level conditions must pass.
|
||||
- `steps`: ordered actions to run when the rule matches.
|
||||
|
||||
## Triggers
|
||||
|
||||
### `created`
|
||||
|
||||
Runs after a meeting note is created.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
- created: {}
|
||||
```
|
||||
|
||||
### `state_transition`
|
||||
|
||||
Runs when the assistant context state transitions. `from` and `to` are optional filters; omitted filters match any value.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
- state_transition:
|
||||
from: collecting metadata
|
||||
to: transcribing
|
||||
```
|
||||
|
||||
Supported state names are:
|
||||
|
||||
- `collecting metadata`
|
||||
- `transcribing`
|
||||
- `speaker recognition`
|
||||
- `summarizing`
|
||||
- `finished`
|
||||
- `error`
|
||||
|
||||
### `speaker_identified`
|
||||
|
||||
Runs when speaker identification reports a display name. `name` is optional; when supplied, it matches case-insensitively.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
- speaker_identified:
|
||||
name: Ada
|
||||
```
|
||||
|
||||
## Conditions
|
||||
|
||||
Conditions use NCalc expressions. Dotted workflow variables may be written directly; the engine rewrites them into NCalc parameters internally.
|
||||
|
||||
```yaml
|
||||
if:
|
||||
- condition: meeting.attendees.count = 0
|
||||
```
|
||||
|
||||
Available condition variables:
|
||||
|
||||
- `meeting.attendees.count`
|
||||
- `meeting.attendees`
|
||||
- `meeting.projects`
|
||||
- `meeting.title`
|
||||
- `meeting.state`
|
||||
- `event.type`
|
||||
- `state.from`
|
||||
- `state.to`
|
||||
- `speaker.name`
|
||||
|
||||
Available helper functions:
|
||||
|
||||
- `contains(haystack, needle)`: case-insensitive string or collection contains.
|
||||
- `starts_with(value, prefix)`: case-insensitive string prefix check.
|
||||
- `ends_with(value, suffix)`: case-insensitive string suffix check.
|
||||
|
||||
Nested boolean groups are supported:
|
||||
|
||||
```yaml
|
||||
if:
|
||||
- and:
|
||||
- condition: contains(meeting.title, '[External]')
|
||||
- not:
|
||||
condition: contains(meeting.projects, 'Internal')
|
||||
```
|
||||
|
||||
Top-level `if` entries are combined with `and`.
|
||||
|
||||
## Step Templating
|
||||
|
||||
Step `value` fields can be plain strings or Razor templates. Razor templates receive this model:
|
||||
|
||||
- `Model.Meeting.Title`
|
||||
- `Model.Meeting.Attendees`
|
||||
- `Model.Meeting.Projects`
|
||||
- `Model.Meeting.State`
|
||||
- `Model.Event.Type`
|
||||
- `Model.Event.From`
|
||||
- `Model.Event.To`
|
||||
- `Model.Speaker.Name`
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: "Known speaker identified: @Model.Speaker.Name"
|
||||
```
|
||||
|
||||
The engine invokes Razor when the value contains an unescaped `@` that is not part of a
|
||||
valid email address token. Literal email addresses such as `Support@contoso.com` are left
|
||||
unchanged; if the same value also contains a Razor expression, the email `@` is escaped
|
||||
before rendering so the address still appears normally in the rendered result.
|
||||
|
||||
## Steps
|
||||
|
||||
### `add_attendee`
|
||||
|
||||
Adds a normalized attendee display name to meeting note frontmatter if it is not already present case-insensitively.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
```
|
||||
|
||||
### `remove_attendee`
|
||||
|
||||
Removes attendees whose normalized display name matches the rendered value.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: remove_attendee
|
||||
value: "Guest"
|
||||
```
|
||||
|
||||
### `set_property`
|
||||
|
||||
Sets a supported meeting property. The initial supported property is `title` or `meeting.title`.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: title
|
||||
value: "@Model.Meeting.Title.Replace('[External] ', '')"
|
||||
```
|
||||
|
||||
### `add_context`
|
||||
|
||||
Appends rendered text to the assistant context artifact body. This step does not count as a meeting-note mutation and does not cause a meeting-note save by itself.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: "Ada was identified; check project ownership notes."
|
||||
```
|
||||
|
||||
### `add_project`
|
||||
|
||||
Adds a project name to meeting note frontmatter if it is not already present case-insensitively.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: add_project
|
||||
value: Meeting Assistant
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Add a default attendee only for empty meetings:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: add-me
|
||||
on:
|
||||
- created: {}
|
||||
if:
|
||||
- condition: meeting.attendees.count = 0
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: Manuel
|
||||
```
|
||||
|
||||
Remove a title marker after metadata collection:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: clean-external-marker
|
||||
on:
|
||||
- state_transition:
|
||||
from: collecting metadata
|
||||
if:
|
||||
- condition: starts_with(meeting.title, '[External] ')
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: title
|
||||
value: "@Model.Meeting.Title.Replace(\"[External] \", \"\")"
|
||||
```
|
||||
|
||||
Add context when a speaker is identified:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: note-ada
|
||||
on:
|
||||
- speaker_identified:
|
||||
name: Ada
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: "Ada was identified. Check whether the architecture decision log needs an update."
|
||||
```
|
||||
|
||||
Add context when metadata gathering ends with a specific attendee:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: note-review-owner
|
||||
on:
|
||||
- state_transition:
|
||||
from: collecting metadata
|
||||
if:
|
||||
- condition: contains(meeting.attendees, 'Manuel')
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: "Manuel is attending; include implementation follow-ups in the summary."
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Core files:
|
||||
|
||||
- `MeetingAssistant/Workflow/MeetingWorkflowEngine.cs`
|
||||
- `MeetingAssistant/Workflow/MeetingWorkflowModels.cs`
|
||||
- `MeetingAssistant/Workflow/MeetingWorkflowEvent.cs`
|
||||
- `MeetingAssistant/Workflow/FileMeetingWorkflowRulesProvider.cs`
|
||||
- `MeetingAssistant/Recording/MeetingRecordingCoordinator.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs`
|
||||
- `MeetingAssistant.Tests/MeetingWorkflowDiagnosticEndpointTests.cs`
|
||||
|
||||
When extending the workflow engine:
|
||||
|
||||
1. Update OpenSpec requirements if behavior changes.
|
||||
2. Add behavior tests through `MeetingWorkflowEngine` or the recording coordinator event surface.
|
||||
3. Keep the supported trigger, condition, template model, and step lists in this document current.
|
||||
4. Keep `README.md` as the short operational overview and link back here for details.
|
||||
5. Keep the local rules file ignored by git; do not commit personal automation rules.
|
||||
@@ -1,11 +0,0 @@
|
||||
## 1. Build Targets
|
||||
|
||||
- [ ] 1.1 Add explicit macOS and Linux target framework/runtime build coverage.
|
||||
- [ ] 1.2 Keep Windows-only implementations excluded from macOS and Linux builds.
|
||||
- [ ] 1.3 Add platform service registration for unsupported or future macOS/Linux capture and hotkey implementations.
|
||||
|
||||
## 2. Tests
|
||||
|
||||
- [ ] 2.1 Split platform-specific tests by compilation target or runtime guard.
|
||||
- [ ] 2.2 Add CI/build commands that verify Windows, macOS, and Linux compile targets.
|
||||
- [ ] 2.3 Add at least one portable compile test that proves Windows-only packages are not required by non-Windows builds.
|
||||
@@ -0,0 +1,15 @@
|
||||
## Why
|
||||
Meeting Assistant currently has one global configuration, one hotkey, and one implicit ASR/runtime setup. In practice, the user needs to start meetings with different speech-recognition settings, such as German-only versus English-only recognition, without editing configuration and restarting between meetings.
|
||||
|
||||
## What Changes
|
||||
- Add named launch profiles to configuration.
|
||||
- Treat the root `MeetingAssistant` configuration as the `default` launch profile.
|
||||
- Resolve non-default launch profiles by copying the default settings and binding the named profile override onto that copy.
|
||||
- Add profile-aware endpoint routes while preserving the existing default-profile URLs.
|
||||
- Register one global hotkey per launch profile, requiring distinct hotkey definitions.
|
||||
- Add an `english` launch profile that keeps the current default settings except for English Azure Speech recognition and its own hotkey.
|
||||
|
||||
## Impact
|
||||
- Existing URLs and the existing hotkey continue to operate the default profile.
|
||||
- Named profiles are addressed through profile URLs, allowing diagnostics and recording commands to select the desired backend/language profile.
|
||||
- Speech pipeline creation becomes profile-aware.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Recording can be launched through named profiles
|
||||
Meeting Assistant SHALL treat the root `MeetingAssistant` configuration as a launch profile named `default`.
|
||||
|
||||
Meeting Assistant SHALL allow additional named launch profiles to be configured as overrides of the default profile. A named profile SHALL be resolved by binding the default profile first, then binding the named profile override onto the same option object.
|
||||
|
||||
Meeting Assistant SHALL preserve existing recording endpoint URLs as default-profile URLs and SHALL provide equivalent named-profile URLs that include the profile name.
|
||||
|
||||
Each launch profile SHALL have a distinct global hotkey. Pressing a profile hotkey SHALL toggle recording using that profile's resolved configuration.
|
||||
|
||||
Meeting Assistant SHALL use the selected launch profile's vault and recording storage settings when it creates meeting notes, transcripts, assistant context notes, summary notes, temporary recordings, and dictation-word inputs for that meeting.
|
||||
|
||||
Meeting Assistant SHALL use the selected launch profile's summary-agent settings when it runs the summary pipeline for that meeting.
|
||||
|
||||
#### Scenario: Default profile keeps existing recording URL
|
||||
- **WHEN** the user calls the existing recording start URL without a profile name
|
||||
- **THEN** Meeting Assistant starts recording with the `default` launch profile
|
||||
|
||||
#### Scenario: Named profile starts recording
|
||||
- **WHEN** the user calls the named-profile recording start URL for profile `english`
|
||||
- **THEN** Meeting Assistant starts recording using the resolved `english` launch profile configuration
|
||||
|
||||
#### Scenario: Named profile stores meeting artifacts in profile folders
|
||||
- **GIVEN** launch profile `english` overrides vault folders and summary-agent settings
|
||||
- **WHEN** the user starts and stops a recording with launch profile `english`
|
||||
- **THEN** Meeting Assistant creates the meeting note, transcript, assistant context note, and summary note using the resolved `english` vault folders
|
||||
- **AND** Meeting Assistant runs the summary pipeline using the resolved `english` summary-agent settings
|
||||
|
||||
#### Scenario: Profile hotkeys must be distinct
|
||||
- **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
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Speech recognition diagnostics are launch-profile aware
|
||||
Meeting Assistant SHALL preserve existing ASR diagnostic endpoint URLs as default-profile URLs.
|
||||
|
||||
Meeting Assistant SHALL provide equivalent named-profile ASR diagnostic endpoint URLs that include the profile name.
|
||||
|
||||
When a named-profile ASR diagnostic endpoint is used, Meeting Assistant SHALL create the speech recognition pipeline from the resolved profile configuration.
|
||||
|
||||
#### Scenario: Default ASR diagnostic URL uses default profile
|
||||
- **WHEN** the user submits a WAV file to the existing ASR diagnostic endpoint URL
|
||||
- **THEN** Meeting Assistant streams the WAV through the default launch profile's configured speech recognition pipeline
|
||||
|
||||
#### Scenario: Named ASR diagnostic URL uses named profile
|
||||
- **WHEN** the user submits a WAV file to the ASR diagnostic endpoint URL for profile `english`
|
||||
- **THEN** Meeting Assistant streams the WAV through the resolved `english` launch profile's configured speech recognition pipeline
|
||||
@@ -0,0 +1,18 @@
|
||||
## 1. Specification
|
||||
- [x] 1.1 Define launch profile requirements for recording control and endpoint routing.
|
||||
- [x] 1.2 Define launch profile requirements for profile-aware ASR pipeline selection.
|
||||
|
||||
## 2. Implementation
|
||||
- [x] 2.1 Add launch profile option resolution with default-plus-override merge behavior.
|
||||
- [x] 2.2 Preserve existing default endpoint URLs and add named profile URL variants.
|
||||
- [x] 2.3 Pass selected launch profiles into recording and ASR diagnostics pipeline creation.
|
||||
- [x] 2.4 Register distinct hotkeys for all launch profiles.
|
||||
- [x] 2.5 Add an `english` launch profile override to appsettings.
|
||||
- [x] 2.6 Use the selected launch profile for meeting artifact storage paths, dictation words, and summary-agent settings.
|
||||
|
||||
## 3. Validation
|
||||
- [x] 3.1 Add tests for default and named launch profile option resolution.
|
||||
- [x] 3.2 Add tests for profile-aware diagnostic endpoint routing.
|
||||
- [x] 3.3 Add tests for distinct profile hotkey planning.
|
||||
- [x] 3.4 Add tests for profile-scoped meeting artifact paths and summary-agent settings.
|
||||
- [x] 3.5 Run focused tests.
|
||||
+7
@@ -83,6 +83,8 @@ When a match is confirmed and the identity has a canonical name, Meeting Assista
|
||||
|
||||
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
||||
|
||||
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
|
||||
|
||||
#### Scenario: Finished transcript is relabeled after a confirmed match
|
||||
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||
@@ -100,6 +102,11 @@ When a match is confirmed and the matched speaker is not already listed in meeti
|
||||
- **WHEN** live or final speaker matching confirms a diarized speaker is `Christopher`
|
||||
- **THEN** Meeting Assistant does not add another attendee for `Christopher`
|
||||
|
||||
#### Scenario: Calendar attendee aliases are deduplicated
|
||||
- **GIVEN** the speaker identity database contains canonical speaker `Karl Berger` with alias `Berger, Karl`
|
||||
- **WHEN** calendar metadata provides attendees `Karl Berger` and `Berger, Karl`
|
||||
- **THEN** Meeting Assistant writes a single attendee `Karl Berger` to the meeting note
|
||||
|
||||
#### Scenario: Attendee identities are tried first
|
||||
- **GIVEN** the meeting attendees include names matching known identity display names or aliases
|
||||
- **WHEN** Meeting Assistant tries to identify a diarized speaker
|
||||
+3
-1
@@ -18,6 +18,7 @@
|
||||
- [x] 2.13 Run live speaker identification incrementally when new unmapped speakers appear or attendees change.
|
||||
- [x] 2.14 Maintain speaker identity last-modified timestamps for active-age filtering.
|
||||
- [x] 2.15 Add identified speaker display names to meeting note attendees without duplicating display names or aliases.
|
||||
- [x] 2.16 Canonicalize calendar attendee names through identity canonical names and aliases before writing meeting notes.
|
||||
|
||||
## 3. Validation
|
||||
- [x] 3.1 Add tests for candidate creation, elimination, canonical promotion, and reset on empty candidate intersection.
|
||||
@@ -31,4 +32,5 @@
|
||||
- [x] 3.9 Add tests for incremental live speaker identification triggers.
|
||||
- [x] 3.10 Add tests for speaker identity last-modified timestamp creation, updates, and schema upgrade.
|
||||
- [x] 3.11 Add tests for adding matched speaker identities to meeting attendees and alias duplicate suppression.
|
||||
- [x] 3.12 Run `dotnet test`.
|
||||
- [x] 3.12 Add tests for calendar attendee canonicalization and alias duplicate suppression.
|
||||
- [x] 3.13 Run `dotnet test`.
|
||||
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
Azure Speech post-stream refinement can improve final segment quality while preserving the existing low-latency streaming transcription flow.
|
||||
|
||||
## What Changes
|
||||
- Add an `AzureSpeech.PostProcessingOption` setting.
|
||||
- Keep the default option unset while the live Azure backend uses `ConversationTranscriber`.
|
||||
- Apply the configured option only for compatible Azure Speech SDK recognizer paths; skip it for `ConversationTranscriber` to preserve working live diarized transcription.
|
||||
- Use a Speech resource region that supports post-stream refinement.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `meeting-transcription`
|
||||
- Affected code: Azure Speech options, Azure streaming transcription provider, appsettings, Azure provider tests
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Azure Speech post-stream refinement is configurable
|
||||
Meeting Assistant SHALL allow the Azure Speech streaming transcription provider to configure the Speech SDK post-processing option.
|
||||
|
||||
When configured for a compatible Azure Speech SDK recognizer, Meeting Assistant SHALL set `SpeechServiceResponse_PostProcessingOption` on the Azure Speech SDK `SpeechConfig`.
|
||||
|
||||
The default application configuration SHALL keep `PostProcessingOption` empty while using live `ConversationTranscriber` and SHALL use a region that supports post-stream refinement for future compatible recognizer paths.
|
||||
|
||||
Meeting Assistant SHALL keep using the existing Azure live streaming transcription flow rather than switching to batch or offline processing.
|
||||
|
||||
When the Azure live streaming backend uses `ConversationTranscriber`, Meeting Assistant SHALL prefer working live diarized transcription over applying a post-processing option that is only documented for `SpeechRecognizer`.
|
||||
|
||||
#### Scenario: Azure Speech post-refinement is configured
|
||||
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||
- **AND** the configured Azure Speech recognizer supports SDK post-processing
|
||||
- **THEN** Meeting Assistant sets `SpeechServiceResponse_PostProcessingOption` to `PostRefinement` on the Azure Speech SDK configuration
|
||||
|
||||
#### Scenario: Azure live conversation transcription remains available
|
||||
- **WHEN** `AzureSpeech.PostProcessingOption` is `PostRefinement`
|
||||
- **AND** the Azure backend is using live `ConversationTranscriber`
|
||||
- **THEN** Meeting Assistant skips the post-processing SDK property
|
||||
- **AND** continues using live conversation transcription
|
||||
|
||||
#### Scenario: Azure Speech post-processing is unset
|
||||
- **WHEN** `AzureSpeech.PostProcessingOption` is empty
|
||||
- **THEN** Meeting Assistant does not set a post-processing option on the Azure Speech SDK configuration
|
||||
@@ -0,0 +1,14 @@
|
||||
## 1. Specification
|
||||
- [x] 1.1 Define Azure Speech post-stream refinement configuration behavior.
|
||||
|
||||
## 2. Implementation
|
||||
- [x] 2.1 Add configurable Azure Speech post-processing option.
|
||||
- [x] 2.2 Apply the configured option only to compatible Azure Speech SDK recognizer paths and skip it for live `ConversationTranscriber`.
|
||||
- [x] 2.3 Configure Azure Speech for a post-stream-refinement-supported region.
|
||||
- [x] 2.4 Provision or reuse an Azure Speech resource and set the local key environment variable.
|
||||
|
||||
## 3. Validation
|
||||
- [x] 3.1 Add provider tests for the post-processing option.
|
||||
- [x] 3.2 Run focused provider tests.
|
||||
- [x] 3.3 Run full test suite.
|
||||
- [x] 3.4 Run OpenSpec validation.
|
||||
+4
@@ -2,6 +2,10 @@
|
||||
|
||||
Meeting Assistant currently focuses V1 runtime support on Windows capture and Outlook enrichment. We need a separate future change to define macOS and Linux build targets without expanding the V1 scope.
|
||||
|
||||
## Status
|
||||
|
||||
Not planned for V1. This change is intentionally iced without implementation or spec updates.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Define supported macOS and Linux build targets for Meeting Assistant.
|
||||
@@ -0,0 +1,15 @@
|
||||
## Status
|
||||
|
||||
Not planned for V1; archived without spec changes.
|
||||
|
||||
## 1. Build Targets
|
||||
|
||||
- [x] 1.1 Not planned for V1: add explicit macOS and Linux target framework/runtime build coverage.
|
||||
- [x] 1.2 Not planned for V1: keep Windows-only implementations excluded from macOS and Linux builds.
|
||||
- [x] 1.3 Not planned for V1: add platform service registration for unsupported or future macOS/Linux capture and hotkey implementations.
|
||||
|
||||
## 2. Tests
|
||||
|
||||
- [x] 2.1 Not planned for V1: split platform-specific tests by compilation target or runtime guard.
|
||||
- [x] 2.2 Not planned for V1: add CI/build commands that verify Windows, macOS, and Linux compile targets.
|
||||
- [x] 2.3 Not planned for V1: add at least one portable compile test that proves Windows-only packages are not required by non-Windows builds.
|
||||
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
Speaker identity usage is currently tracked as a denormalized transcript count. That loses which meeting files caused the count and prevents Meeting Assistant from updating past transcript files when an identity is later named or merged.
|
||||
|
||||
## What Changes
|
||||
- Store speaker identity meeting participation as reference rows containing meeting note and transcript file addresses.
|
||||
- Calculate identity usage counts from references when ordering matching candidates.
|
||||
- Add transcript audit lines to referenced transcripts when an identity is named or two identities are merged.
|
||||
|
||||
## Impact
|
||||
- Speaker identity SQLite schema gains a references table.
|
||||
- Matching, attendee canonicalization, and merge ordering use calculated reference counts instead of a persisted counter.
|
||||
- Existing databases keep working; legacy transcript-count columns may remain unused.
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
## 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.
|
||||
|
||||
The speaker identity database SHALL store speaker identities, optional canonical names, aliases, candidate names, meeting file references, and a bounded set of WAV snippets per identity.
|
||||
|
||||
Meeting file references SHALL include the meeting note file address and the transcript file address.
|
||||
|
||||
Meeting Assistant SHALL calculate speaker identity participation counts from meeting file references when needed instead of persisting a denormalized transcript count.
|
||||
|
||||
Each speaker identity SHALL store a last-modified timestamp used by active-age filtering, and Meeting Assistant SHALL update it whenever the identity is created or modified by identification, candidate updates, snippet changes, reference changes, or merge operations.
|
||||
|
||||
The configured maximum snippet count per identity SHALL prevent unbounded growth.
|
||||
|
||||
#### 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
|
||||
- **AND** stores a meeting file reference for that identity
|
||||
|
||||
#### Scenario: Speaker snippets are bounded
|
||||
- **WHEN** Meeting Assistant adds a snippet for an identity that already has the configured maximum number of snippets
|
||||
- **THEN** Meeting Assistant does not store more snippets for that identity
|
||||
|
||||
#### Scenario: Identity modification updates active-age timestamp
|
||||
- **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
|
||||
|
||||
### Requirement: Speaker candidates are eliminated across meetings
|
||||
Meeting Assistant SHALL update candidate names for a matched unnamed identity using the intersection of its existing candidate names and the current meeting attendees.
|
||||
|
||||
Meeting Assistant SHALL treat identity aliases as acceptable names during candidate elimination and when excluding already-matched attendees from new unmatched speaker candidates.
|
||||
|
||||
When the candidate intersection leaves exactly one candidate, Meeting Assistant SHALL promote that candidate to the canonical speaker name.
|
||||
|
||||
When an identity receives a canonical name, Meeting Assistant SHALL append an audit line to each referenced transcript in the form `<date> <speaker label> was identified as <name>`.
|
||||
|
||||
When the candidate intersection is empty, Meeting Assistant SHALL replace the oldest stored snippet for that identity and reset candidates from the current meeting attendees, because the previous snippet may have been dirty.
|
||||
|
||||
#### Scenario: Candidate elimination promotes canonical name
|
||||
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane`, `John`, and `Chris`
|
||||
- **THEN** Meeting Assistant removes `Mike` from the identity candidates and promotes `John` as the canonical name
|
||||
- **AND** appends the identity naming audit line to the identity's referenced transcripts
|
||||
|
||||
#### Scenario: Alias participates in candidate elimination
|
||||
- **GIVEN** an unnamed identity has candidate name `Michael` and alias `Mike`
|
||||
- **WHEN** that identity matches a speaker in a later meeting with attendee `Mike`
|
||||
- **THEN** Meeting Assistant treats `Mike` as matching `Michael`
|
||||
- **AND** promotes `Michael` as the canonical name
|
||||
|
||||
#### Scenario: Empty candidate intersection resets candidates
|
||||
- **GIVEN** an unnamed identity has candidate names `John` and `Mike`
|
||||
- **WHEN** that identity matches a speaker in a later meeting with attendees `Jane` and `Chris`
|
||||
- **THEN** Meeting Assistant resets the identity candidates to `Jane` and `Chris`
|
||||
- **AND** replaces the oldest stored snippet for that identity with the current speaker snippet
|
||||
|
||||
### Requirement: Speaker identity matches relabel transcripts
|
||||
Meeting Assistant SHALL attempt to match unknown diarized speaker snippets against known speaker identities ordered by calculated meeting reference count.
|
||||
|
||||
Speaker identity matching SHALL use a dedicated Azure Speech diarization verifier component rather than the configured speech recognition pipeline.
|
||||
|
||||
The matcher SHALL test at most the configured batch size of known people per diarization session and continue with later batches until a match is found or no candidates remain.
|
||||
|
||||
The matcher SHALL prioritize identities whose canonical name or aliases match current meeting attendees.
|
||||
|
||||
After attendee-matched identities, the matcher SHALL order identities by calculated meeting reference count, filter out non-attendee identities whose last update is older than the configured active age, and cap the candidate set at the configured maximum match candidate count.
|
||||
|
||||
When a match is confirmed in final identity processing, Meeting Assistant SHALL store a meeting file reference for that identity.
|
||||
|
||||
When a match is confirmed and the identity has a canonical name, Meeting Assistant SHALL rewrite finished transcript segments for that diarized speaker with the canonical name.
|
||||
|
||||
When a match is confirmed and the matched speaker is not already listed in meeting note attendees by display name or alias, Meeting Assistant SHALL add the speaker display name to the attendee list.
|
||||
|
||||
When Meeting Assistant writes attendees from calendar metadata, it SHALL match attendee display names exactly against known identity canonical names and aliases, replace matches with the identity display name, and deduplicate attendees that map to the same identity.
|
||||
|
||||
#### Scenario: Finished transcript is relabeled after a confirmed match
|
||||
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||
- **THEN** Meeting Assistant rewrites `Guest03` segments in the transcript as `Chris`
|
||||
|
||||
#### Scenario: Confirmed match stores meeting reference
|
||||
- **GIVEN** the speaker identity database contains canonical speaker `Chris`
|
||||
- **WHEN** a finished transcript has diarized speaker `Guest03` and the matching backend confirms it is `Chris`
|
||||
- **THEN** Meeting Assistant stores the meeting note and transcript file addresses as a reference for `Chris`
|
||||
|
||||
### Requirement: Speaker identities can be merged diagnostically
|
||||
Meeting Assistant SHALL expose a diagnostic endpoint that merges duplicate speaker identities.
|
||||
|
||||
The merge process SHALL compare recently-created identities, using a configurable recent age that defaults to two weeks, against all other identities in matcher batches bounded by the configured batch size.
|
||||
|
||||
The merge process SHALL require a match and a second validation match using a different source sample before merging two identities.
|
||||
|
||||
When identities are merged, Meeting Assistant SHALL retain one identity, move useful names from the merged identity into aliases, combine meeting file references, retain a bounded set of snippets from both identities, and append an audit line to each referenced transcript in the form `<date> <name 1> and <name 2> were merged`.
|
||||
|
||||
#### Scenario: Recently-created duplicate identity is merged
|
||||
- **GIVEN** a recently-created identity and an older identity have matching speaker snippets
|
||||
- **WHEN** the diagnostic merge endpoint is triggered
|
||||
- **THEN** Meeting Assistant validates the match twice with different source snippets
|
||||
- **AND** merges the recent identity into the older identity
|
||||
- **AND** stores the recent identity name as an alias on the retained identity
|
||||
- **AND** keeps meeting file references from both identities
|
||||
- **AND** appends the merge audit line to the referenced transcripts
|
||||
|
||||
#### Scenario: Old identities are not used as merge sources
|
||||
- **GIVEN** two identities older than the configured recent age
|
||||
- **WHEN** the diagnostic merge endpoint is triggered
|
||||
- **THEN** Meeting Assistant does not compare them as source identities
|
||||
@@ -0,0 +1,10 @@
|
||||
## 1. Implementation
|
||||
- [x] Add speaker identity reference storage and schema creation.
|
||||
- [x] Replace transcript-count ordering and match candidate data with calculated reference counts.
|
||||
- [x] Record meeting note/transcript references when final identity processing matches or creates an identity.
|
||||
- [x] Append transcript audit lines when identities are named or merged.
|
||||
|
||||
## 2. Verification
|
||||
- [x] Add/adjust behavior tests for references, calculated counts, naming audit lines, and merge audit lines.
|
||||
- [x] Run focused speaker identity tests.
|
||||
- [x] Run `openspec validate add-speaker-identity-references --strict`.
|
||||
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
The summary agent can edit supporting files such as project notes or the dictation dictionary. Those writes are currently invisible after the run unless the user notices file changes manually.
|
||||
|
||||
## What Changes
|
||||
- Track in-memory diffs for summary-agent writes outside the summary and assistant context files.
|
||||
- Use a real diff implementation so whole-file rewrites produce useful changed-line output.
|
||||
- Append the collected diff audit to the assistant context when the summary agent finishes.
|
||||
|
||||
## Impact
|
||||
- Adds a .NET diff package dependency.
|
||||
- Summary tool write paths gain audit capture for project files and dictation dictionary writes.
|
||||
- Assistant context files receive an appended audit section for external files changed by the agent.
|
||||
+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.
|
||||
|
||||
Meeting Assistant SHALL use a Microsoft Agent Framework pipeline for the first summary implementation. The pipeline SHALL use a configurable OpenAI-compatible endpoint, model, and API key source, including support for a direct key or an environment variable name.
|
||||
|
||||
The summary pipeline SHALL retry transient model endpoint failures according to configurable reconnection attempts and delay settings.
|
||||
|
||||
The summary pipeline SHALL track context-window usage for the configured model using response usage when available and request-size estimates otherwise. The context-window limit, maximum output reserve, compaction enablement, compaction threshold, and Responses compact endpoint path SHALL be configurable.
|
||||
|
||||
When only the configured remaining context ratio is available, the summary pipeline SHALL try to compact the conversation through the configured OpenAI-compatible `POST /v1/responses/compact` endpoint. If that endpoint is unavailable or returns invalid data, the pipeline SHALL fall back to Microsoft Agent Framework compaction.
|
||||
|
||||
Fallback compaction SHALL become increasingly aggressive: collapse old tool results, summarize older conversation spans, keep only the last four user turns, and drop oldest groups if still over budget.
|
||||
|
||||
When summary generation fails after retries, Meeting Assistant SHALL write a markdown failure report to the configured summary note path. The failure report SHALL include a clickable retry link, error details, and the meeting artifact paths needed to diagnose or retry the run.
|
||||
|
||||
After transcript processing finishes for a recording, Meeting Assistant SHALL automatically invoke the summary pipeline for that meeting.
|
||||
|
||||
Meeting Assistant SHALL expose an API operation to retry summary generation for a given summary note path. The retry operation SHALL resolve the linked meeting artifacts from the meeting note frontmatter and SHALL overwrite the existing summary note with either the new summary or a new failure report.
|
||||
|
||||
The summary pipeline SHALL expose tools scoped to the current meeting:
|
||||
|
||||
- `read_meetingnote`
|
||||
- `read_transcript`
|
||||
- `read_context`
|
||||
- `read_usernotes`
|
||||
- `read_glossary`
|
||||
- `write_summary`
|
||||
- `write_context`
|
||||
- `list_projects`
|
||||
- `list_projectfiles`
|
||||
- `read_projectfile`
|
||||
- `write_projectfile`
|
||||
- `search`
|
||||
|
||||
All summary-agent read tools that return file-like content SHALL support either reading the whole file or reading a clamped inclusive 1-based line range when `from` and `to` are supplied.
|
||||
|
||||
The summary pipeline SHALL write the summary note as a markdown artifact linked to the meeting note, transcript, and assistant context.
|
||||
|
||||
The summary agent SHALL be able to read the meeting note, transcript, assistant context, glossary, and bound project files through tools.
|
||||
|
||||
The summary agent SHALL be able to write the summary and assistant context files as its owned artifacts.
|
||||
|
||||
For summary-agent writes to any file other than the summary file and assistant context file, Meeting Assistant SHALL record an in-memory diff containing removed and added lines.
|
||||
|
||||
Meeting Assistant SHALL use a diff implementation that can reduce whole-file rewrites to changed lines instead of treating every rewrite as a full replacement.
|
||||
|
||||
When the summary agent finishes, Meeting Assistant SHALL append the collected external write diffs to the assistant context file.
|
||||
|
||||
After writing the meeting summary, the summary agent SHALL update existing project files when the meeting produced project-relevant knowledge.
|
||||
|
||||
The assistant context note SHALL keep `title`, `start_time`, `end_time`, `meeting`, `transcript`, `summary`, `state`, and `agenda` in frontmatter. Meeting Assistant SHALL write `title` and `start_time` when the assistant context note is created, update `title` when later meeting metadata is discovered, and write `end_time` when transcript processing stops. When a Teams appointment is detected at recording start, the assistant context note SHALL also keep `scheduled_end` in frontmatter. The `state` value SHALL be one of `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error`.
|
||||
|
||||
The summary note SHALL keep frontmatter links to meeting artifacts and SHALL copy `title`, `start_time`, and `end_time` from the meeting note when available. If the meeting note has no title, the summary agent SHALL be able to provide a title when writing the summary.
|
||||
|
||||
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.
|
||||
|
||||
#### 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
|
||||
- **AND** the assistant context frontmatter state is `transcribing`
|
||||
- **AND** the assistant context frontmatter includes `agenda`, empty when no agenda is known
|
||||
- **AND** the assistant context frontmatter includes `scheduled_end` when Teams metadata supplied a scheduled end time
|
||||
|
||||
#### Scenario: Assistant context state follows meeting processing
|
||||
- **WHEN** transcription, final speaker recognition, and summary generation progress
|
||||
- **THEN** Meeting Assistant updates assistant context frontmatter state to `transcribing`, `speaker recognition`, `summarizing`, `finished`, or `error` as appropriate
|
||||
- **AND** preserves existing `agenda` and `scheduled_end` frontmatter values
|
||||
|
||||
#### Scenario: External project write is audited
|
||||
- **WHEN** the summary agent writes a bound project file
|
||||
- **THEN** Meeting Assistant records the changed removed and added lines for that project file
|
||||
- **AND** appends that diff to the assistant context after the agent finishes
|
||||
|
||||
#### Scenario: Dictionary write is audited
|
||||
- **WHEN** the summary agent adds a dictation word and the dictionary file changes
|
||||
- **THEN** Meeting Assistant records the changed removed and added lines for the dictionary file
|
||||
- **AND** appends that diff to the assistant context after the agent finishes
|
||||
|
||||
#### Scenario: Owned artifact writes are not audited
|
||||
- **WHEN** the summary agent writes the summary or assistant context file
|
||||
- **THEN** Meeting Assistant does not include those writes in the external write diff audit
|
||||
@@ -0,0 +1,9 @@
|
||||
## 1. Implementation
|
||||
- [x] Add an in-memory summary-agent write audit component.
|
||||
- [x] Capture project-file and dictation-dictionary diffs while excluding summary and assistant-context writes.
|
||||
- [x] Append the collected audit to assistant context after the summary agent finishes.
|
||||
|
||||
## 2. Verification
|
||||
- [x] Add behavior tests for project-file diff capture, dictionary diff capture, and summary/context exclusion.
|
||||
- [x] Run focused summary/project tool tests.
|
||||
- [x] Run `openspec validate add-summary-agent-write-diff-audit --strict`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user