Public Access
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561e37e3a5 |
@@ -123,49 +123,6 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActiveRecordingPromptAttachesTheSelectedAppointmentMetadataWithoutRestarting()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: true, autoAcceptPrompts: false);
|
||||
var firstMetadata = new MeetingMetadata(
|
||||
"First planning",
|
||||
["Ada"],
|
||||
"First agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var secondMetadata = new MeetingMetadata(
|
||||
"Second planning",
|
||||
["Grace"],
|
||||
"Second agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var firstMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-first",
|
||||
subject: "First planning",
|
||||
metadata: firstMetadata);
|
||||
var secondMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-second",
|
||||
subject: "Second planning",
|
||||
metadata: secondMetadata);
|
||||
harness.Provider.Meetings = [firstMeeting, secondMeeting];
|
||||
|
||||
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
||||
harness.Clock.Now = firstMeeting.Start;
|
||||
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
||||
await harness.PromptService.RespondAsync(
|
||||
secondMeeting,
|
||||
MeetingStartPromptResponse.AttachMetadataToCurrentMeeting);
|
||||
|
||||
Assert.All(
|
||||
harness.PromptService.PromptRequests,
|
||||
request => Assert.True(request.CanAttachToCurrentMeeting));
|
||||
Assert.Equal(["attach-metadata"], harness.Recorder.Commands);
|
||||
Assert.Same(secondMetadata, harness.Recorder.AttachedMetadata.Single());
|
||||
Assert.Equal(0, harness.Recorder.StopCount);
|
||||
Assert.Equal(0, harness.Recorder.StartCount);
|
||||
Assert.True(harness.Recorder.CurrentStatus.IsRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledCachedMeetingDoesNotPromptRecording()
|
||||
{
|
||||
@@ -345,17 +302,14 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
this.autoAccept = autoAccept;
|
||||
}
|
||||
|
||||
public List<MeetingStartPromptRequest> PromptRequests { get; } = [];
|
||||
|
||||
public IReadOnlyList<CalendarMeeting> PromptedMeetings =>
|
||||
PromptRequests.Select(request => request.Meeting).ToList();
|
||||
public List<CalendarMeeting> PromptedMeetings { get; } = [];
|
||||
|
||||
public async Task ShowPromptAsync(
|
||||
MeetingStartPromptRequest request,
|
||||
Func<MeetingStartPromptResponse, CancellationToken, Task> handleResponseAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PromptRequests.Add(request);
|
||||
PromptedMeetings.Add(request.Meeting);
|
||||
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
|
||||
if (autoAccept)
|
||||
{
|
||||
@@ -395,8 +349,6 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
|
||||
public List<MeetingMetadata?> StartMetadata { get; } = [];
|
||||
|
||||
public List<MeetingMetadata> AttachedMetadata { get; } = [];
|
||||
|
||||
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return StartRecordingAsync(null);
|
||||
@@ -428,15 +380,6 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
return Task.FromResult(CurrentStatus);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
|
||||
MeetingMetadata metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Commands.Add("attach-metadata");
|
||||
AttachedMetadata.Add(metadata);
|
||||
return Task.FromResult(CurrentStatus);
|
||||
}
|
||||
|
||||
private static RecordingStatus Status(bool isRecording)
|
||||
{
|
||||
return new RecordingStatus(
|
||||
|
||||
@@ -13,50 +13,21 @@ namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExtractUsesAgentStreamingTransportByDefault()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var handler = new RecordingHandler(
|
||||
CreateStreamedTextResponse("Streamed OCR text"),
|
||||
"text/event-stream");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Endpoint = "https://summary.local",
|
||||
Model = "vision-model",
|
||||
Key = "agent-key",
|
||||
UseStreaming = true
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Streamed OCR text", result.Text);
|
||||
using var payload = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.True(payload.RootElement.GetProperty("stream").GetBoolean());
|
||||
var message = Assert.Single(payload.RootElement.GetProperty("input").EnumerateArray());
|
||||
Assert.Equal("message", message.GetProperty("type").GetString());
|
||||
var content = message.GetProperty("content").EnumerateArray().ToArray();
|
||||
Assert.Equal("input_text", content[0].GetProperty("type").GetString());
|
||||
Assert.Equal("Extract screenshot.", content[0].GetProperty("text").GetString());
|
||||
Assert.Equal("input_image", content[1].GetProperty("type").GetString());
|
||||
Assert.Equal("data:image/png;base64,AQID", content[1].GetProperty("image_url").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var handler = new RecordingHandler(CreateNonStreamingTextResponse("Visible slide text"));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"content": [
|
||||
{ "type": "output_text", "text": "Visible slide text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
@@ -66,8 +37,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
Endpoint = "https://summary.local",
|
||||
Model = "summary-model",
|
||||
Key = "agent-key",
|
||||
UseStreaming = false
|
||||
Key = "agent-key"
|
||||
},
|
||||
Screenshots =
|
||||
{
|
||||
@@ -92,7 +62,6 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
Assert.Equal("Bearer", handler.Authorization?.Scheme);
|
||||
Assert.Equal("ocr-key", handler.Authorization?.Parameter);
|
||||
using var payload = JsonDocument.Parse(handler.RequestBody!);
|
||||
Assert.False(payload.RootElement.GetProperty("stream").GetBoolean());
|
||||
Assert.Equal("summary-model", payload.RootElement.GetProperty("model").GetString());
|
||||
var content = payload.RootElement
|
||||
.GetProperty("input")[0]
|
||||
@@ -105,7 +74,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([4, 5, 6]);
|
||||
var handler = new RecordingHandler(CreateNonStreamingTextResponse("OCR result"));
|
||||
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
@@ -115,8 +84,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
Endpoint = "https://summary.local",
|
||||
Model = "summary-model",
|
||||
Key = "agent-key",
|
||||
UseStreaming = false
|
||||
Key = "agent-key"
|
||||
},
|
||||
Screenshots =
|
||||
{
|
||||
@@ -145,14 +113,11 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler(CreateNonStreamingTextResponse(
|
||||
"""
|
||||
Slide text
|
||||
|
||||
```json
|
||||
{ "crop": { "x": 1, "y": 2, "width": 3, "height": 4 } }
|
||||
```
|
||||
"""));
|
||||
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);
|
||||
@@ -160,8 +125,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key",
|
||||
UseStreaming = false
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,14 +148,11 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
public async Task ExtractParsesAttendeeMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var handler = new RecordingHandler(CreateNonStreamingTextResponse(
|
||||
"""
|
||||
Visible participant tiles: Ada and Grace.
|
||||
|
||||
```json
|
||||
{ "crop": null, "attendees": ["Ada Lovelace", "Grace Hopper"] }
|
||||
```
|
||||
"""));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Visible participant tiles: Ada and Grace.\n\n```json\n{ \"crop\": null, \"attendees\": [\"Ada Lovelace\", \"Grace Hopper\"] }\n```"
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
@@ -199,8 +160,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key",
|
||||
UseStreaming = false
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -218,14 +178,11 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
public async Task ExtractIgnoresMalformedAttendeesMetadataAndStillParsesCrop()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler(CreateNonStreamingTextResponse(
|
||||
"""
|
||||
Slide text
|
||||
|
||||
```json
|
||||
{ "crop": { "x": 1, "y": 2, "width": 3, "height": 4 }, "attendees": "Ada" }
|
||||
```
|
||||
"""));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 }, \"attendees\": \"Ada\" }\n```"
|
||||
}
|
||||
""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
NullLogger<LiteLlmScreenshotOcrClient>.Instance);
|
||||
@@ -233,8 +190,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key",
|
||||
UseStreaming = false
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,12 +208,10 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string responseBody;
|
||||
private readonly string mediaType;
|
||||
|
||||
public RecordingHandler(string responseBody, string mediaType = "application/json")
|
||||
public RecordingHandler(string responseBody)
|
||||
{
|
||||
this.responseBody = responseBody;
|
||||
this.mediaType = mediaType;
|
||||
}
|
||||
|
||||
public Uri? RequestUri { get; private set; }
|
||||
@@ -277,7 +231,7 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, mediaType)
|
||||
Content = new StringContent(responseBody, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -295,55 +249,6 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string CreateNonStreamingTextResponse(string text)
|
||||
{
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
id = "resp_ocr",
|
||||
created_at = 1779147100,
|
||||
model = "vision-model",
|
||||
@object = "response",
|
||||
output = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = "msg_ocr",
|
||||
type = "message",
|
||||
status = "completed",
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "output_text",
|
||||
annotations = Array.Empty<object>(),
|
||||
text
|
||||
}
|
||||
},
|
||||
role = "assistant"
|
||||
}
|
||||
},
|
||||
parallel_tool_calls = true,
|
||||
status = "completed",
|
||||
store = false
|
||||
});
|
||||
}
|
||||
|
||||
private static string CreateStreamedTextResponse(string text)
|
||||
{
|
||||
var delta = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "response.output_text.delta",
|
||||
item_id = "msg_ocr",
|
||||
output_index = 0,
|
||||
content_index = 0,
|
||||
delta = text
|
||||
});
|
||||
return
|
||||
$"data: {delta}{Environment.NewLine}{Environment.NewLine}" +
|
||||
"""data: {"type":"response.completed","response":{"id":"resp_ocr","created_at":1779147100,"model":"vision-model","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false}}""" +
|
||||
$"{Environment.NewLine}{Environment.NewLine}data: [DONE]{Environment.NewLine}{Environment.NewLine}";
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScreenshotAsync(byte[] bytes)
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
|
||||
@@ -189,9 +189,7 @@ public sealed class MeetingSummaryRetryRunnerTests
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.WhenAny(
|
||||
stateChanged.Task,
|
||||
Task.Delay(TimeSpan.FromMilliseconds(100)));
|
||||
await stateChanged.Task.WaitAsync(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
throw new TimeoutException($"Expected {count} state changes.");
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class OutlookMeetingCandidateSelectorTests
|
||||
{
|
||||
var now = new DateTime(2026, 5, 20, 10, 0, 0);
|
||||
var endingOverlap = new Candidate(now.AddMinutes(-25), now.AddMinutes(2));
|
||||
var upcoming = new Candidate(now.AddMinutes(1), now.AddMinutes(31));
|
||||
var upcoming = new Candidate(now.AddMinutes(5), now.AddMinutes(35));
|
||||
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
[endingOverlap, upcoming],
|
||||
|
||||
@@ -900,185 +900,6 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachPromptedMetadataUpdatesTheActiveMeetingWithoutInterruptingRecording()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\active-metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var workflowEngine = new TransformingAttendeeWorkflowEngine(
|
||||
"Ada Lovelace (Contoso)",
|
||||
"Ada Lovelace");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: new CountingMeetingMetadataProvider(),
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
var started = await coordinator.StartAsync(CancellationToken.None);
|
||||
noteStore.UpdateSavedNote(noteStore.SavedNote! with { UserNotes = "Keep this user note." });
|
||||
var promptedMetadata = new MeetingMetadata(
|
||||
"Selected architecture sync",
|
||||
["Ada Lovelace (Contoso)"],
|
||||
"Review the selected architecture",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
|
||||
|
||||
var attached = await coordinator.AttachMetadataToCurrentMeetingAsync(
|
||||
promptedMetadata,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(started.IsRecording);
|
||||
Assert.True(attached.IsRecording);
|
||||
Assert.Equal(started.TranscriptPath, attached.TranscriptPath);
|
||||
Assert.Equal("Selected architecture sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada Lovelace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Keep this user note.", noteStore.SavedNote?.UserNotes);
|
||||
Assert.Equal("Selected architecture sync", artifactStore.ContextMeetingNote?.Frontmatter.Title);
|
||||
Assert.Equal("Review the selected architecture", artifactStore.Agenda);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
artifactStore.ScheduledEnd);
|
||||
Assert.Equal(2, transcriptStore.MetadataUpdateCount);
|
||||
Assert.Equal("Selected architecture sync", transcriptStore.MetadataMeetingNote?.Frontmatter.Title);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachedPromptMetadataWinsOverAStandaloneLookupThatCompletesLater()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\explicit-metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Background calendar match",
|
||||
["Grace"],
|
||||
"Background agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider);
|
||||
var promptedMetadata = new MeetingMetadata(
|
||||
"Explicit prompted appointment",
|
||||
["Ada"],
|
||||
"Explicit agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await metadataProvider.WaitUntilRequestedAsync();
|
||||
await coordinator.AttachMetadataToCurrentMeetingAsync(promptedMetadata, CancellationToken.None);
|
||||
metadataProvider.Release();
|
||||
await WaitUntilAsync(() => artifactStore.States.Contains(AssistantContextState.Transcribing));
|
||||
|
||||
Assert.Equal("Explicit prompted appointment", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Explicit agenda", artifactStore.Agenda);
|
||||
Assert.Equal(
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"),
|
||||
artifactStore.ScheduledEnd);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttachPromptedMetadataDoesNothingAfterTheActiveRecordingStops()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var transcriptStore = new InMemoryTranscriptStore();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\stopped-metadata-meeting.md");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
transcriptStore,
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: new CountingMeetingMetadataProvider());
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
var stopped = await coordinator.StopAsync(CancellationToken.None);
|
||||
var metadataUpdatesBeforeAttach = transcriptStore.MetadataUpdateCount;
|
||||
var titleBeforeAttach = noteStore.SavedNote?.Frontmatter.Title;
|
||||
|
||||
var result = await coordinator.AttachMetadataToCurrentMeetingAsync(
|
||||
new MeetingMetadata(
|
||||
"Stale prompted appointment",
|
||||
["Ada"],
|
||||
"Stale agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(stopped.IsRecording);
|
||||
Assert.False(result.IsRecording);
|
||||
Assert.Equal(titleBeforeAttach, noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(metadataUpdatesBeforeAttach, transcriptStore.MetadataUpdateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FailedPromptMetadataAttachmentDoesNotSuppressTheBackgroundLookup()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\failed-attach-metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore(failFirstMetadataUpdate: true);
|
||||
var metadataProvider = new BlockingMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Background calendar fallback",
|
||||
["Grace"],
|
||||
"Background fallback agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T12:00:00+02:00")));
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await metadataProvider.WaitUntilRequestedAsync();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
coordinator.AttachMetadataToCurrentMeetingAsync(
|
||||
new MeetingMetadata(
|
||||
"Prompt attachment that fails",
|
||||
["Ada"],
|
||||
"Prompt agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00")),
|
||||
CancellationToken.None));
|
||||
metadataProvider.Release();
|
||||
await WaitUntilAsync(() => artifactStore.States.Contains(AssistantContextState.Transcribing));
|
||||
|
||||
Assert.Equal("Background calendar fallback", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Grace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Background fallback agenda", artifactStore.Agenda);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTransformsMetadataAttendeesBeforeWritingNote()
|
||||
{
|
||||
@@ -3004,8 +2825,6 @@ public sealed class RecordingCoordinatorTests
|
||||
|
||||
public MeetingNote? MetadataMeetingNote { get; private set; }
|
||||
|
||||
public int MetadataUpdateCount { get; private set; }
|
||||
|
||||
public Task ReplaceLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> replacementLines,
|
||||
@@ -3021,7 +2840,6 @@ public sealed class RecordingCoordinatorTests
|
||||
MeetingNote meetingNote,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MetadataUpdateCount++;
|
||||
MetadataMeetingNote = meetingNote;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
@@ -3448,14 +3266,10 @@ public sealed class RecordingCoordinatorTests
|
||||
private sealed class InMemoryMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
private readonly bool createAssistantContextFile;
|
||||
private bool failNextMetadataUpdate;
|
||||
|
||||
public InMemoryMeetingArtifactStore(
|
||||
bool createAssistantContextFile = false,
|
||||
bool failFirstMetadataUpdate = false)
|
||||
public InMemoryMeetingArtifactStore(bool createAssistantContextFile = false)
|
||||
{
|
||||
this.createAssistantContextFile = createAssistantContextFile;
|
||||
failNextMetadataUpdate = failFirstMetadataUpdate;
|
||||
}
|
||||
|
||||
public MeetingSessionArtifacts? CreatedArtifacts { get; private set; }
|
||||
@@ -3513,12 +3327,6 @@ public sealed class RecordingCoordinatorTests
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (failNextMetadataUpdate)
|
||||
{
|
||||
failNextMetadataUpdate = false;
|
||||
throw new InvalidOperationException("Metadata artifact update failed.");
|
||||
}
|
||||
|
||||
ContextMeetingNote = meetingNote;
|
||||
Agenda = agenda;
|
||||
ScheduledEnd = scheduledEnd;
|
||||
@@ -3623,7 +3431,6 @@ public sealed class RecordingCoordinatorTests
|
||||
private sealed class BlockingMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
private readonly MeetingMetadata metadata;
|
||||
private readonly TaskCompletionSource requested = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public BlockingMeetingMetadataProvider(MeetingMetadata metadata)
|
||||
@@ -3636,16 +3443,10 @@ public sealed class RecordingCoordinatorTests
|
||||
release.TrySetResult();
|
||||
}
|
||||
|
||||
public Task WaitUntilRequestedAsync()
|
||||
{
|
||||
return requested.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public async Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
requested.TrySetResult();
|
||||
await release.Task.WaitAsync(cancellationToken);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -13,23 +13,22 @@ public sealed class TaskbarIconTests
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(),
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L")],
|
||||
[new MicrophoneDevice("integrated", "integrated microphone")],
|
||||
"integrated");
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Idle, menu.State);
|
||||
AssertMenuLayout(
|
||||
menu,
|
||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||
("Microphone", MeetingTaskbarAction.OpenSubmenu, true),
|
||||
("Start meeting recording (default)\tCtrl+Alt+M", MeetingTaskbarAction.StartRecording, false),
|
||||
("Start meeting recording (english)\tCtrl+Alt+L", MeetingTaskbarAction.StartRecording, false),
|
||||
("Exit", MeetingTaskbarAction.Exit, true));
|
||||
Assert.Equal(
|
||||
["default", "english"],
|
||||
menu.Items
|
||||
.Where(item => item.Action == MeetingTaskbarAction.StartRecording)
|
||||
.Select(item => item.ProfileName));
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.EditRules &&
|
||||
item.Text == "Open agent");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default" &&
|
||||
item.Text == "Start meeting recording (default)\tCtrl+Alt+M");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "english" &&
|
||||
item.Text == "Start meeting recording (english)\tCtrl+Alt+L");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,41 +60,27 @@ public sealed class TaskbarIconTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuPrioritizesFinishMeetingInDedicatedSection()
|
||||
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L")],
|
||||
[new MicrophoneDevice("integrated", "integrated microphone")],
|
||||
"integrated");
|
||||
[Profile("default", "Ctrl+Alt+M"), Profile("english", "Ctrl+Alt+L"), Profile("french", "Ctrl+Alt+F")]);
|
||||
|
||||
Assert.Equal(RecordingProcessState.Recording, menu.State);
|
||||
AssertMenuLayout(
|
||||
menu,
|
||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||
("Microphone", MeetingTaskbarAction.OpenSubmenu, true),
|
||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, false),
|
||||
("Switch to english\tCtrl+Alt+L", MeetingTaskbarAction.SwitchProfile, false),
|
||||
("Exit", MeetingTaskbarAction.Exit, true));
|
||||
Assert.Equal(
|
||||
"english",
|
||||
Assert.Single(menu.Items, item => item.Action == MeetingTaskbarAction.SwitchProfile).ProfileName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuKeepsFinishMeetingIsolatedWithoutMicrophones()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(isRecording: true, state: RecordingProcessState.Recording, profile: "default"),
|
||||
[Profile("default")]);
|
||||
|
||||
AssertMenuLayout(
|
||||
menu,
|
||||
("Open agent", MeetingTaskbarAction.EditRules, false),
|
||||
("Finish meeting", MeetingTaskbarAction.StopRecording, true),
|
||||
("Cancel meeting recording and discard", MeetingTaskbarAction.AbortRecording, true),
|
||||
("Exit", MeetingTaskbarAction.Exit, true));
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.StopRecording);
|
||||
Assert.Contains(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "english" &&
|
||||
item.Text == "Switch to english\tCtrl+Alt+L");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "french" &&
|
||||
item.Text == "Switch to french\tCtrl+Alt+F");
|
||||
Assert.DoesNotContain(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SwitchProfile &&
|
||||
item.ProfileName == "default");
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.StartRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -188,15 +173,6 @@ public sealed class TaskbarIconTests
|
||||
});
|
||||
}
|
||||
|
||||
private static void AssertMenuLayout(
|
||||
MeetingTaskbarMenu menu,
|
||||
params (string Text, MeetingTaskbarAction Action, bool StartsSection)[] expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
menu.Items.Select(item => (item.Text, item.Action, item.StartsSection)));
|
||||
}
|
||||
|
||||
private static RecordingStatus Status(
|
||||
bool isRecording = false,
|
||||
RecordingProcessState state = RecordingProcessState.Idle,
|
||||
|
||||
@@ -98,9 +98,7 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
meeting.Subject,
|
||||
meeting.Start);
|
||||
await promptService.ShowPromptAsync(
|
||||
new MeetingStartPromptRequest(
|
||||
meeting,
|
||||
recordingController.CurrentStatus.IsRecording && meeting.Metadata is not null),
|
||||
new MeetingStartPromptRequest(meeting),
|
||||
(response, token) => HandlePromptResponseAsync(meeting, response, token),
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -141,18 +139,6 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
MeetingStartPromptResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (response == MeetingStartPromptResponse.AttachMetadataToCurrentMeeting)
|
||||
{
|
||||
if (meeting.Metadata is not null)
|
||||
{
|
||||
await recordingController.AttachMetadataToCurrentMeetingAsync(
|
||||
meeting.Metadata,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (response != MeetingStartPromptResponse.Record)
|
||||
{
|
||||
return;
|
||||
@@ -258,15 +244,12 @@ public interface IMeetingStartPromptService
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingStartPromptRequest(
|
||||
CalendarMeeting Meeting,
|
||||
bool CanAttachToCurrentMeeting = false);
|
||||
public sealed record MeetingStartPromptRequest(CalendarMeeting Meeting);
|
||||
|
||||
public enum MeetingStartPromptResponse
|
||||
{
|
||||
Record,
|
||||
Skip,
|
||||
AttachMetadataToCurrentMeeting
|
||||
Skip
|
||||
}
|
||||
|
||||
public interface IMeetingPromptRecordingController
|
||||
@@ -279,10 +262,6 @@ public interface IMeetingPromptRecordingController
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
|
||||
MeetingMetadata metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<RecordingStatus> StopAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -309,13 +288,6 @@ public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingCo
|
||||
return coordinator.StartFromPromptAsync(metadata, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
|
||||
MeetingMetadata metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return coordinator.AttachMetadataToCurrentMeetingAsync(metadata, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return coordinator.StopAsync(cancellationToken);
|
||||
|
||||
@@ -118,10 +118,21 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
|
||||
string promptId,
|
||||
MeetingStartPromptRequest request)
|
||||
{
|
||||
var yesButton = BuildResponseButton("Yes", promptId, "record");
|
||||
var noButton = BuildResponseButton("No", promptId, "skip");
|
||||
var yesButton = new ToastButton()
|
||||
.SetContent("Yes")
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", "record")
|
||||
.SetBackgroundActivation();
|
||||
|
||||
var notification = new ToastContentBuilder()
|
||||
var noButton = new ToastButton()
|
||||
.SetContent("No")
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", "skip")
|
||||
.SetBackgroundActivation();
|
||||
|
||||
return new ToastContentBuilder()
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
@@ -130,30 +141,6 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
|
||||
.AddText($"{request.Meeting.Subject} starts at {request.Meeting.Start.LocalDateTime:t}.")
|
||||
.AddButton(yesButton)
|
||||
.AddButton(noButton);
|
||||
|
||||
if (request.CanAttachToCurrentMeeting)
|
||||
{
|
||||
notification.AddButton(
|
||||
BuildResponseButton(
|
||||
"Add metadata to current meeting",
|
||||
promptId,
|
||||
"attach-metadata"));
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
private static ToastButton BuildResponseButton(
|
||||
string content,
|
||||
string promptId,
|
||||
string response)
|
||||
{
|
||||
return new ToastButton()
|
||||
.SetContent(content)
|
||||
.AddArgument("source", NotificationSource)
|
||||
.AddArgument("promptId", promptId)
|
||||
.AddArgument("response", response)
|
||||
.SetBackgroundActivation();
|
||||
}
|
||||
|
||||
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
||||
@@ -179,14 +166,10 @@ public sealed class WindowsMeetingStartPromptService : IMeetingStartPromptServic
|
||||
return;
|
||||
}
|
||||
|
||||
var response = TryGetArgument(arguments, "response", out var responseValue)
|
||||
? responseValue.ToLowerInvariant() switch
|
||||
{
|
||||
"record" => MeetingStartPromptResponse.Record,
|
||||
"attach-metadata" => MeetingStartPromptResponse.AttachMetadataToCurrentMeeting,
|
||||
_ => MeetingStartPromptResponse.Skip
|
||||
}
|
||||
: MeetingStartPromptResponse.Skip;
|
||||
var response = TryGetArgument(arguments, "response", out var responseValue) &&
|
||||
string.Equals(responseValue, "record", StringComparison.OrdinalIgnoreCase)
|
||||
? MeetingStartPromptResponse.Record
|
||||
: MeetingStartPromptResponse.Skip;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
<MicrosoftSpeechVersion>1.51.1</MicrosoftSpeechVersion>
|
||||
<MicrosoftSpeechVersion>1.50.0</MicrosoftSpeechVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="6.4.0" />
|
||||
<PackageReference Include="NCalcSync" Version="7.0.1" />
|
||||
<PackageReference Include="RazorLight" Version="2.3.1" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.10" />
|
||||
@@ -50,7 +50,8 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\*.fpie" Link="runtimes\win-x64\native\MASmodels\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\aec_v1.fpie" Link="runtimes\win-x64\native\MASmodels\aec_v1.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
<None Include="$(NuGetPackageRoot)microsoft.cognitiveservices.speech.extension.mas\$(MicrosoftSpeechVersion)\contentFiles\any\any\models\pns_avg4.fpie" Link="runtimes\win-x64\native\MASmodels\pns_avg4.fpie" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -155,44 +155,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> AttachMetadataToCurrentMeetingAsync(
|
||||
MeetingMetadata metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var run = currentRun;
|
||||
if (run is null || run.IsCaptureStopping)
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
|
||||
run.Artifacts,
|
||||
await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken),
|
||||
metadata,
|
||||
run.Options,
|
||||
cancellationToken);
|
||||
currentMeetingNote = meetingNote;
|
||||
await transcriptStore.UpdateMetadataAsync(
|
||||
run.Session,
|
||||
run.Artifacts,
|
||||
meetingNote,
|
||||
cancellationToken);
|
||||
run.MarkPromptMetadataAttached();
|
||||
|
||||
logger.LogInformation(
|
||||
"Attached prompted calendar metadata to active meeting {MeetingNotePath}",
|
||||
run.MeetingNotePath);
|
||||
return CurrentStatus;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StartAsync(
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -251,12 +213,22 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
||||
if (suppliedMetadata is not null)
|
||||
{
|
||||
currentMeetingNote = await ApplyAndPersistMeetingMetadataAsync(
|
||||
await ApplyMeetingMetadataAsync(
|
||||
currentArtifacts,
|
||||
currentMeetingNote,
|
||||
suppliedMetadata,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
currentMeetingNote = await meetingNoteStore.SaveAsync(
|
||||
currentMeetingNote,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
||||
currentArtifacts,
|
||||
currentMeetingNote,
|
||||
suppliedMetadata.Agenda,
|
||||
suppliedMetadata.ScheduledEnd,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(
|
||||
@@ -1049,7 +1021,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.IsAborted || run.HasAttachedPromptMetadata)
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1057,21 +1029,25 @@ public sealed class MeetingRecordingCoordinator
|
||||
await gate.WaitAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (run.IsAborted || run.HasAttachedPromptMetadata)
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var meetingNote = await ApplyAndPersistMeetingMetadataAsync(
|
||||
run.Artifacts,
|
||||
await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None),
|
||||
metadata,
|
||||
run.Options,
|
||||
CancellationToken.None);
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
|
||||
await ApplyMeetingMetadataAsync(run.Artifacts, meetingNote, metadata, run.Options, CancellationToken.None);
|
||||
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
|
||||
if (currentMeetingNote?.Path == meetingNote.Path)
|
||||
{
|
||||
currentMeetingNote = meetingNote;
|
||||
}
|
||||
|
||||
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
||||
run.Artifacts,
|
||||
meetingNote,
|
||||
metadata.Agenda,
|
||||
metadata.ScheduledEnd,
|
||||
CancellationToken.None);
|
||||
logger.LogInformation(
|
||||
"Applied Outlook meeting metadata to {MeetingNotePath}",
|
||||
run.MeetingNotePath);
|
||||
@@ -1143,32 +1119,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<MeetingNote> ApplyAndPersistMeetingMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
MeetingMetadata metadata,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await ApplyMeetingMetadataAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
metadata,
|
||||
options,
|
||||
cancellationToken);
|
||||
var savedMeetingNote = await meetingNoteStore.SaveAsync(
|
||||
meetingNote,
|
||||
options,
|
||||
cancellationToken);
|
||||
await meetingArtifactStore.UpdateAssistantContextMetadataAsync(
|
||||
artifacts,
|
||||
savedMeetingNote,
|
||||
metadata.Agenda,
|
||||
metadata.ScheduledEnd,
|
||||
cancellationToken);
|
||||
return savedMeetingNote;
|
||||
}
|
||||
|
||||
private async Task<List<string>> TransformAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
@@ -1958,8 +1908,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public bool HasSwitchedProfile { get; private set; }
|
||||
|
||||
public bool HasAttachedPromptMetadata { get; private set; }
|
||||
|
||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||
|
||||
public DateTimeOffset? InferredEndTime
|
||||
@@ -1978,11 +1926,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
CaptureCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public void MarkPromptMetadataAttached()
|
||||
{
|
||||
HasAttachedPromptMetadata = true;
|
||||
}
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
IsAborted = true;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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;
|
||||
|
||||
@@ -38,30 +40,20 @@ public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
: options.Agent.Model;
|
||||
var key = ResolveApiKey(options);
|
||||
var imageBytes = await File.ReadAllBytesAsync(screenshotPath, cancellationToken);
|
||||
var httpClient = CreateHttpClient();
|
||||
httpClient.BaseAddress = LiteLlmResponsesChatClient.NormalizeEndpoint(new Uri(endpoint));
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
httpClient,
|
||||
key,
|
||||
model,
|
||||
enableThinking: false,
|
||||
reasoningEffort: "none",
|
||||
reconnectionAttempts: options.Agent.ReconnectionAttempts,
|
||||
reconnectionDelay: options.Agent.ReconnectionDelay,
|
||||
logger: logger,
|
||||
firstRequestIsUser: false,
|
||||
useStreaming: options.Agent.UseStreaming);
|
||||
var response = await chatClient.GetResponseAsync(
|
||||
[
|
||||
new ChatMessage(
|
||||
ChatRole.User,
|
||||
[
|
||||
new TextContent(CreatePrompt(prompt, imageBytes)),
|
||||
new DataContent(imageBytes, "image/png")
|
||||
])
|
||||
],
|
||||
cancellationToken: cancellationToken);
|
||||
var text = response.Text ?? string.Empty;
|
||||
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);
|
||||
}
|
||||
@@ -73,6 +65,35 @@ public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
: 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)
|
||||
@@ -189,6 +210,46 @@ public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
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))
|
||||
@@ -217,6 +278,17 @@ public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
$"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();
|
||||
}
|
||||
|
||||
@@ -659,9 +659,9 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message)
|
||||
{
|
||||
var role = message.Role.Value;
|
||||
var text = message.Text;
|
||||
if (role == ChatRole.System.Value)
|
||||
{
|
||||
var text = message.Text;
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
instructions.AppendLine(text);
|
||||
@@ -670,37 +670,21 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
return;
|
||||
}
|
||||
|
||||
var isAssistant = role == ChatRole.Assistant.Value;
|
||||
var messageContent = new JsonArray();
|
||||
foreach (var content in message.Contents)
|
||||
{
|
||||
if (content is TextContent textContent && !string.IsNullOrWhiteSpace(textContent.Text))
|
||||
{
|
||||
messageContent.Add(new JsonObject
|
||||
{
|
||||
["type"] = isAssistant ? "output_text" : "input_text",
|
||||
["text"] = textContent.Text
|
||||
});
|
||||
}
|
||||
else if (!isAssistant &&
|
||||
content is DataContent dataContent &&
|
||||
dataContent.HasTopLevelMediaType("image"))
|
||||
{
|
||||
messageContent.Add(new JsonObject
|
||||
{
|
||||
["type"] = "input_image",
|
||||
["image_url"] = dataContent.Uri.ToString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (messageContent.Count > 0)
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var isAssistant = role == ChatRole.Assistant.Value;
|
||||
input.Add(new JsonObject
|
||||
{
|
||||
["type"] = "message",
|
||||
["role"] = isAssistant ? "assistant" : "user",
|
||||
["content"] = messageContent
|
||||
["content"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["type"] = isAssistant ? "output_text" : "input_text",
|
||||
["text"] = text
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -777,7 +761,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
return Math.Max(1, (int)Math.Ceiling(json.Length / 4.0));
|
||||
}
|
||||
|
||||
internal static Uri NormalizeEndpoint(Uri endpoint)
|
||||
private static Uri NormalizeEndpoint(Uri endpoint)
|
||||
{
|
||||
var value = endpoint.ToString().TrimEnd('/');
|
||||
if (!value.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -26,8 +26,7 @@ public sealed record MeetingTaskbarMenuItem(
|
||||
string? ProfileName = null,
|
||||
string? MicrophoneDeviceId = null,
|
||||
bool IsChecked = false,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem>? Items = null,
|
||||
bool StartsSection = false);
|
||||
IReadOnlyList<MeetingTaskbarMenuItem>? Items = null);
|
||||
|
||||
public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
@@ -42,29 +41,23 @@ public static class MeetingTaskbarMenuBuilder
|
||||
new("Open agent", MeetingTaskbarAction.EditRules)
|
||||
};
|
||||
|
||||
if (microphones is { Count: > 0 })
|
||||
{
|
||||
items.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
||||
}
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Finish meeting",
|
||||
MeetingTaskbarAction.StopRecording,
|
||||
StartsSection: true));
|
||||
}
|
||||
|
||||
var secondaryControls = new List<MeetingTaskbarMenuItem>();
|
||||
if (microphones is { Count: > 0 })
|
||||
{
|
||||
secondaryControls.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
||||
}
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
secondaryControls.Add(new MeetingTaskbarMenuItem(
|
||||
"Stop meeting recording and transcribe",
|
||||
MeetingTaskbarAction.StopRecording));
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Cancel meeting recording and discard",
|
||||
MeetingTaskbarAction.AbortRecording));
|
||||
|
||||
foreach (var profile in launchProfiles.Where(profile => !IsActiveProfile(profile, status)))
|
||||
{
|
||||
secondaryControls.Add(new MeetingTaskbarMenuItem(
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
AppendHotkey($"Switch to {profile.Name}", profile.Options.Hotkey.Toggle),
|
||||
MeetingTaskbarAction.SwitchProfile,
|
||||
profile.Name));
|
||||
@@ -74,18 +67,16 @@ public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
foreach (var profile in launchProfiles)
|
||||
{
|
||||
secondaryControls.Add(new MeetingTaskbarMenuItem(
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
AppendHotkey($"Start meeting recording ({profile.Name})", profile.Options.Hotkey.Toggle),
|
||||
MeetingTaskbarAction.StartRecording,
|
||||
profile.Name));
|
||||
}
|
||||
}
|
||||
|
||||
AddSection(items, secondaryControls);
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
"Exit",
|
||||
MeetingTaskbarAction.Exit,
|
||||
StartsSection: true));
|
||||
MeetingTaskbarAction.Exit));
|
||||
|
||||
return new MeetingTaskbarMenu(
|
||||
status.State,
|
||||
@@ -111,19 +102,6 @@ public static class MeetingTaskbarMenuBuilder
|
||||
Items: microphoneItems);
|
||||
}
|
||||
|
||||
private static void AddSection(
|
||||
List<MeetingTaskbarMenuItem> items,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem> section)
|
||||
{
|
||||
if (section.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
items.Add(section[0] with { StartsSection = true });
|
||||
items.AddRange(section.Skip(1));
|
||||
}
|
||||
|
||||
private static string BuildTooltip(RecordingStatus status)
|
||||
{
|
||||
return status.State switch
|
||||
|
||||
@@ -196,12 +196,14 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
var popupMenu = new PopupMenu();
|
||||
for (var index = 0; index < menu.Items.Count; index++)
|
||||
{
|
||||
var menuItem = menu.Items[index];
|
||||
if (index > 0 && menuItem.StartsSection)
|
||||
if (index == 1 ||
|
||||
(menu.Items[index].Action == MeetingTaskbarAction.Exit &&
|
||||
menu.Items[index - 1].Action != MeetingTaskbarAction.EditRules))
|
||||
{
|
||||
popupMenu.Items.Add(new PopupMenuSeparator());
|
||||
}
|
||||
|
||||
var menuItem = menu.Items[index];
|
||||
popupMenu.Items.Add(BuildPopupItem(menuItem));
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
return string.Join(
|
||||
"|",
|
||||
FlattenMenuItems(menu.Items).Select(item =>
|
||||
$"{item.Action}:{item.ProfileName}:{item.MicrophoneDeviceId}:{item.IsChecked}:{item.StartsSection}:{item.Text}"));
|
||||
$"{item.Action}:{item.ProfileName}:{item.MicrophoneDeviceId}:{item.IsChecked}:{item.Text}"));
|
||||
}
|
||||
|
||||
private static IEnumerable<MeetingTaskbarMenuItem> FlattenMenuItems(
|
||||
|
||||
@@ -317,7 +317,7 @@ When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Cl
|
||||
|
||||
`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.
|
||||
|
||||
`Screenshots:Ocr` optionally enables vision extraction for screenshots. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. Screenshot OCR also inherits `Agent:UseStreaming`, using the Responses SSE transport when it is `true` and the non-streaming Responses transport when it is `false`. Automatic summarization scans the meeting note for user-added Obsidian image embeds such as `![[whiteboard.png]]` and Markdown image embeds such as ``, adds resolvable local images to the assistant context without copying them or changing the meeting note, runs OCR without crop or attendee updates, and waits for all pending OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. Failed or timed-out screenshot OCR writes a retry link that targets `/meetings/screenshot-ocr/retry` with the exact saved screenshot and OCR block id.
|
||||
`Screenshots:Ocr` optionally enables vision extraction for screenshots. Blank `Endpoint` or `Model` values fall back to the summary `Agent` endpoint and model. `Key` or `KeyEnv` can be set specifically for OCR; otherwise the summary agent key configuration is used. Automatic summarization scans the meeting note for user-added Obsidian image embeds such as `![[whiteboard.png]]` and Markdown image embeds such as ``, adds resolvable local images to the assistant context without copying them or changing the meeting note, runs OCR without crop or attendee updates, and waits for all pending OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`. Failed or timed-out screenshot OCR writes a retry link that targets `/meetings/screenshot-ocr/retry` with the exact saved screenshot and OCR block id.
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-29
|
||||
@@ -1,34 +0,0 @@
|
||||
## Context
|
||||
|
||||
`LiteLlmScreenshotOcrClient` currently builds and posts a raw Responses JSON payload, then parses the successful body as one JSON document. It inherits endpoint, model, and key values from `AgentOptions`, but never reads `AgentOptions.UseStreaming`. The summary and workflow agents already use `LiteLlmResponsesChatClient`, which selects the OpenAI SDK streaming or non-streaming Responses method and maps both through the Microsoft.Extensions.AI adapter.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Make screenshot OCR use `Agent:UseStreaming` without adding another setting.
|
||||
- Reuse the supported Responses SDK transport and response adapter.
|
||||
- Preserve screenshot-specific endpoint, model, key, prompt, image, crop, attendee, and timeout behavior.
|
||||
- Keep non-streaming screenshot OCR working when streaming is disabled.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Expose OCR token deltas to the UI or assistant context.
|
||||
- Change screenshot OCR retry, crop, attendee, or note-block semantics.
|
||||
- Add file upload or remote image URL support.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Route screenshot OCR through `LiteLlmResponsesChatClient` instead of maintaining a second Responses parser. The screenshot client will construct one user chat message containing prompt text and PNG `DataContent`, then consume the buffered `ChatResponse.Text`. This keeps transport selection, SDK request creation, SSE assembly, and non-streaming mapping in one client.
|
||||
|
||||
2. Extend the shared Responses message translator to map image `DataContent` to an `input_image` content block using its data URI. Text and image blocks remain in one `type: message` input item, matching the existing OCR payload shape.
|
||||
|
||||
3. Use `AgentOptions.UseStreaming` for screenshot OCR even when the screenshot-specific endpoint or model overrides are set. Endpoint/model/key remain independently overrideable; transport is an agent-wide behavior setting.
|
||||
|
||||
4. Disable reasoning and compaction for the one-turn OCR request, preserving the existing screenshot client behavior while still using the agent reconnection settings and selected transport.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Shared-client diagnostics mention summary context]** Some low-level logs are named for the summary pipeline. → Avoid passing summary compaction state and keep screenshot-specific completion/failure logs at the screenshot client boundary.
|
||||
- **[Multimodal translation expands shared client scope]** Incorrect content mapping could affect summary requests. → Add request-body behavior coverage proving prompt and PNG data URI are preserved, while existing summary message tests protect text translation.
|
||||
- **[Provider image support varies]** A configured model may reject image input. → Preserve the provider error and existing screenshot OCR failure/retry behavior.
|
||||
@@ -1,24 +0,0 @@
|
||||
## Why
|
||||
|
||||
Screenshot OCR inherits its endpoint, model, and key from the summary agent, but it bypasses the shared Responses client and ignores `Agent:UseStreaming`. With streaming enabled, the screenshot client still expects one JSON document and cannot consume the configured LiteLLM Responses event stream.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Make screenshot OCR inherit the summary agent's streaming transport selection.
|
||||
- Send screenshot image input through the same OpenAI Responses SDK and Agent Framework adapter used by the summary client.
|
||||
- Preserve the existing non-streaming screenshot OCR path when streaming is disabled.
|
||||
- Add behavior coverage for streamed and non-streamed screenshot OCR responses.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-summary`: Require screenshot OCR to honor the configured agent streaming transport while preserving image input and OCR metadata parsing.
|
||||
|
||||
## Impact
|
||||
|
||||
The change affects the screenshot OCR client, the shared LiteLLM Responses message translation, focused tests, and agent configuration documentation. It does not change the local HTTP API or screenshot note format.
|
||||
@@ -1,103 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Meeting screenshots are captured into assistant context
|
||||
Meeting Assistant SHALL expose a configurable screenshot hotkey.
|
||||
|
||||
When a meeting is active and the screenshot hotkey is pressed, Meeting Assistant SHALL capture the currently active window.
|
||||
|
||||
The screenshot image SHALL be saved into a configurable attachments folder for the assistant context note. By default, the folder SHALL be `Attachments` beside the assistant context note.
|
||||
|
||||
After the image is saved, Meeting Assistant SHALL append a markdown image link to the assistant context note with a meeting-relative timestamp that correlates to transcript timestamps.
|
||||
|
||||
Meeting Assistant SHALL allow optional screenshot OCR configuration with endpoint URL, API key or key environment variable, model, prompt, and timeout.
|
||||
|
||||
When screenshot OCR is configured, Meeting Assistant SHALL send the screenshot and prompt to the configured OpenAI-compatible Responses endpoint and append the model result after the screenshot link in the assistant context note.
|
||||
|
||||
Screenshot OCR SHALL honor the configured `MeetingAssistant:Agent:UseStreaming` transport selection.
|
||||
|
||||
When streaming is enabled, screenshot OCR SHALL consume the Responses result through the supported OpenAI Responses and Agent Framework Server-Sent Events adapter.
|
||||
|
||||
When streaming is disabled, screenshot OCR SHALL consume the result through the supported non-streaming OpenAI Responses client and adapter.
|
||||
|
||||
The screenshot OCR prompt SHALL ask the model to return pixel crop coordinates when it can confidently isolate only the presentation, shared screen, or similarly relevant meeting content.
|
||||
|
||||
When OCR returns valid crop coordinates within the original image bounds, Meeting Assistant SHALL save a cropped PNG beside the original screenshot and SHALL link the cropped image before the OCR result in the assistant context note.
|
||||
|
||||
When OCR returns no crop coordinates or invalid crop coordinates, Meeting Assistant SHALL keep the original screenshot link and OCR result without writing a cropped image.
|
||||
|
||||
After transcription finishes and before summarization starts, Meeting Assistant SHALL scan the meeting note for user-authored Obsidian image embeds and Markdown image embeds.
|
||||
|
||||
When configured screenshot OCR is enabled and the meeting note contains image embeds, Meeting Assistant SHALL append each resolvable image to the assistant context note, state that the image came from the meeting note, preserve the original embed text for cross-reference, and run OCR for the linked image.
|
||||
|
||||
Meeting-note image OCR SHALL NOT copy the image file, SHALL NOT write crop images, SHALL NOT add attendees from OCR metadata, and SHALL NOT modify the meeting note.
|
||||
|
||||
Meeting Assistant SHALL wait for meeting-note image OCR to finish or time out before transitioning the assistant context to summarizing.
|
||||
|
||||
When screenshot OCR fails or times out, Meeting Assistant SHALL write the failure status into the assistant context note with a retry link for that exact screenshot.
|
||||
|
||||
When the screenshot OCR retry link is activated, Meeting Assistant SHALL rerun OCR for the saved screenshot and replace that screenshot's OCR block in the assistant context note.
|
||||
|
||||
When screenshot OCR is not configured, Meeting Assistant SHALL skip OCR and keep the screenshot link.
|
||||
|
||||
The default OCR prompt SHALL explain that the image is from a meeting and ask the model to identify who is talking, who is presenting, what is presented, capture slide text in markdown, convert diagrams to Mermaid when possible, indicate whether visible people are clearly the exact meeting participants or only a partial result, return crop coordinates only for confidently isolated presentation/shared-screen content, and otherwise describe the scene.
|
||||
|
||||
#### Scenario: Screenshot is linked with meeting timestamp
|
||||
- **GIVEN** a meeting started at `10:00:00`
|
||||
- **WHEN** the user captures a screenshot at `10:03:05`
|
||||
- **THEN** Meeting Assistant saves the screenshot under the configured attachments folder
|
||||
- **AND** appends a markdown image link to assistant context with timestamp `[00:03:05]`
|
||||
|
||||
#### Scenario: OCR result is appended after screenshot
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **WHEN** the user captures a screenshot
|
||||
- **THEN** Meeting Assistant appends the screenshot link to assistant context
|
||||
- **AND** appends the OCR result for that screenshot after the link when processing completes
|
||||
|
||||
#### Scenario: Streaming screenshot OCR is assembled
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** `MeetingAssistant:Agent:UseStreaming` is `true`
|
||||
- **WHEN** the Responses endpoint returns screenshot OCR output as Server-Sent Events
|
||||
- **THEN** Meeting Assistant sends the prompt and screenshot as one multimodal Responses message
|
||||
- **AND** appends the assembled OCR text without a JSON document parse failure
|
||||
|
||||
#### Scenario: Screenshot OCR streaming can be disabled
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** `MeetingAssistant:Agent:UseStreaming` is `false`
|
||||
- **WHEN** Meeting Assistant requests screenshot OCR
|
||||
- **THEN** it uses the supported non-streaming Responses client and adapter
|
||||
- **AND** preserves the prompt and screenshot image input
|
||||
|
||||
#### Scenario: OCR crop is saved and linked before OCR text
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** OCR returns valid crop coordinates for a shared screen
|
||||
- **WHEN** OCR processing completes
|
||||
- **THEN** Meeting Assistant saves a cropped screenshot beside the original image
|
||||
- **AND** links the cropped screenshot before the OCR text in assistant context
|
||||
|
||||
#### Scenario: Meeting note image embeds are OCRed before summarization
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** the meeting note contains `![[whiteboard.png]]`
|
||||
- **AND** the meeting note contains ``
|
||||
- **WHEN** transcription finishes
|
||||
- **THEN** Meeting Assistant appends both images to the assistant context as images from the meeting note
|
||||
- **AND** includes the original embed text for each image
|
||||
- **AND** runs OCR for each image without copying files, writing crop images, adding attendees, or modifying the meeting note
|
||||
- **AND** waits for this OCR to finish or time out before transitioning to summarizing
|
||||
|
||||
#### Scenario: OCR failure can be retried for the same screenshot
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** OCR fails or times out for a captured screenshot
|
||||
- **WHEN** Meeting Assistant writes the OCR failure status
|
||||
- **THEN** the assistant context includes a retry link for that exact screenshot
|
||||
- **WHEN** the retry link is activated
|
||||
- **THEN** Meeting Assistant reruns OCR against the saved screenshot
|
||||
- **AND** replaces that screenshot's OCR block with the retry result
|
||||
|
||||
#### Scenario: OCR is skipped when not configured
|
||||
- **GIVEN** screenshot OCR is not configured
|
||||
- **WHEN** the user captures a screenshot
|
||||
- **THEN** Meeting Assistant saves and links the screenshot without calling a model endpoint
|
||||
|
||||
#### Scenario: OCR reports whether visible people are complete or partial
|
||||
- **WHEN** Meeting Assistant uses the built-in screenshot OCR prompt
|
||||
- **THEN** the prompt asks the model to state whether the screenshot clearly shows exactly who is in the meeting or only a partial participant result
|
||||
@@ -1,15 +0,0 @@
|
||||
## 1. Streaming screenshot OCR
|
||||
|
||||
- [x] 1.1 Add a failing screenshot-client behavior test for an SSE response with prompt and image input.
|
||||
- [x] 1.2 Route screenshot OCR through the shared Responses client and add multimodal message translation.
|
||||
|
||||
## 2. Non-streaming compatibility
|
||||
|
||||
- [x] 2.1 Add behavior coverage proving `Agent:UseStreaming=false` preserves non-streaming screenshot OCR and image input.
|
||||
- [x] 2.2 Document that screenshot OCR inherits the agent streaming setting.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 Refactor the touched screenshot and shared client paths for DRYness, SOLID boundaries, and simplicity while preserving behavior.
|
||||
- [x] 3.2 Run focused tests, the full solution tests, and strict OpenSpec validation.
|
||||
- [x] 3.3 Restart Meeting Assistant only while idle and verify screenshot OCR against the deployed LiteLLM endpoint.
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-05
|
||||
@@ -1,69 +0,0 @@
|
||||
## Context
|
||||
|
||||
Standalone Outlook enrichment already selects a single suitable Teams appointment and already contains a fixed five-minute upcoming-start window, but that behavior is not represented in the accepted specification. The selector intentionally returns no metadata when the suitable candidates are ambiguous. Calendar notifications avoid that ambiguity because each notification carries one cached appointment and its metadata.
|
||||
|
||||
The prompt scheduler currently offers `Record` and `Skip`. Choosing `Record` while a meeting is active stops that meeting and starts a new run with the prompted metadata. Metadata application is currently private to recording startup/background lookup, so the scheduler has no safe way to enrich the active run in place.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Specify and preserve the existing five-minute pre-start Outlook metadata grace period.
|
||||
- Preserve conservative standalone selection when more than one appointment is suitable.
|
||||
- Offer an appointment-specific metadata action only when a recording is active and the prompt carries metadata.
|
||||
- Apply that metadata atomically to the active run without interrupting capture or transcription.
|
||||
- Prevent a slower standalone Outlook lookup from overwriting explicitly attached prompt metadata.
|
||||
- Reuse existing attendee canonicalization, attendee-added workflow transformations, attendee import limits, and artifact rendering.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Automatically choose among concurrent appointments.
|
||||
- Change the existing `Yes` behavior that finishes the current recording and starts the prompted appointment as a new recording.
|
||||
- Add a new workflow trigger or replay an already-completed lifecycle state transition.
|
||||
- Guarantee an exact action-button row layout that the native Windows toast renderer does not expose to the application.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Keep the five-minute grace period in the shared candidate selector
|
||||
|
||||
The current selector already treats exactly one appointment starting within five minutes as eligible when there is no suitable overlap. The change will codify this behavior in OpenSpec and retain its behavior test. Candidate selection remains conservative: multiple suitable overlaps or multiple upcoming candidates return no selection.
|
||||
|
||||
This keeps manual enrichment independent of the calendar prompt cache. Using the prompt cache for all metadata lookup was considered, but it would couple ordinary recording startup to an optional hosted feature and its sync freshness.
|
||||
|
||||
### Put active-recording capability on the prompt request
|
||||
|
||||
The scheduler will snapshot whether the prompt can attach metadata when it calls the prompt service. The Windows adapter will add a third `Add metadata to current meeting` background action after the existing `Yes` and `No` actions only when that flag is true. The response enum will carry a distinct attach result, so the callback still identifies the exact cached appointment.
|
||||
|
||||
The native toast API controls final action layout and does not provide a reliable per-button full-row placement contract. Adding the action after the two short actions gives the renderer the best available ordering while keeping the label explicit.
|
||||
|
||||
### Add one coordinator operation for explicit active-run metadata
|
||||
|
||||
`IMeetingPromptRecordingController` will expose an attach operation backed by `MeetingRecordingCoordinator`. Under the coordinator gate, it will require a currently capturing run, mark that run as explicitly assigned, re-read the latest meeting note, apply the shared metadata rules, save the note, and refresh assistant-context and transcript metadata. Capture and transcription continue unchanged.
|
||||
|
||||
Centralizing the mutation in the coordinator keeps run ownership, artifact paths, profile options, and serialization under the same synchronization boundary. Direct file mutation from the scheduler was rejected because it could race recording lifecycle writes and would bypass attendee normalization and workflow transformations.
|
||||
|
||||
### Explicit prompt metadata wins over background lookup
|
||||
|
||||
Each recording run will track whether appointment metadata was explicitly assigned. The background Outlook task will check this flag before and after acquiring the coordinator gate. If explicit prompt metadata has already been attached, the background result is discarded. If the background update wins the gate first, the later explicit action overwrites it, so the user's appointment choice remains authoritative.
|
||||
|
||||
### Do not replay lifecycle transitions
|
||||
|
||||
Attendee-added transformations run as part of the shared metadata application path. The meeting has already transitioned from `collecting metadata` to `transcribing`, so the attach action will not fabricate or replay that state transition. Existing live speaker matching already observes changes to meeting-note attendees.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
[Native toast may not render the third action as a full-width row] -> Keep it as the final, clearly labelled action and let Windows choose the physical layout.
|
||||
|
||||
[The recording stops before the user activates the notification] -> Recheck active capture under the coordinator gate and leave artifacts unchanged if there is no current recording.
|
||||
|
||||
[Metadata attachment replaces title and attendee metadata] -> Reuse the established prompted-start semantics so the selected appointment becomes authoritative while preserving the note body and other user-authored content.
|
||||
|
||||
[A delayed background lookup races the explicit action] -> Record explicit assignment on the run and make the explicit appointment win regardless of completion order.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No data or configuration migration is required. The notification gains one conditional action, and existing `Yes`/`No` activation arguments remain valid. Rollback removes the action and coordinator method without changing stored meeting artifacts.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -1,28 +0,0 @@
|
||||
## Why
|
||||
|
||||
Manual recordings started shortly before an appointment currently miss Outlook metadata, while concurrent appointments make automatic lookup ambiguous. Once an appointment-specific recording notification appears during an active recording, the user also has no way to attach that exact appointment's metadata without stopping the current meeting.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Treat a Teams appointment that starts within five minutes after a manual recording begins as current for Outlook metadata enrichment.
|
||||
- Keep standalone metadata lookup conservative when multiple in-progress appointments match, or when multiple appointments fall in the grace window and none is already in progress.
|
||||
- When a calendar recording prompt is shown during an active recording, add an `Add metadata to current meeting` action below the existing affirmative and negative actions when the Windows notification layout permits it.
|
||||
- Apply the prompted appointment's title, eligible attendees, agenda, and scheduled end to the active meeting without stopping or starting a recording.
|
||||
- Reuse the normal attendee transformation and artifact-update behavior when prompted metadata is attached to an active meeting.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-session`: Extend Outlook enrichment with a five-minute pre-start grace period and allow appointment-specific prompt metadata to be attached to an active meeting.
|
||||
|
||||
## Impact
|
||||
|
||||
- Outlook current-meeting candidate selection and its Windows provider.
|
||||
- Calendar prompt scheduling, response contracts, and Windows toast layout/activation handling.
|
||||
- The active recording controller/coordinator metadata update surface.
|
||||
- Meeting-note, assistant-context, transcript metadata, and workflow behavior tests.
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Windows Outlook enrichment is optional
|
||||
Meeting Assistant SHALL gate Outlook Classic COM enrichment behind the Windows compilation target.
|
||||
|
||||
For standalone metadata lookup, Meeting Assistant SHALL first consider Teams appointments that are in progress with at least five minutes remaining. If exactly one such appointment exists, Meeting Assistant SHALL select it even when another appointment starts within five minutes.
|
||||
|
||||
If no suitable in-progress appointment exists, Meeting Assistant SHALL consider Teams appointments that start within five minutes after recording starts and SHALL select one only when exactly one such upcoming appointment exists.
|
||||
|
||||
When the Windows build starts a meeting and Outlook Classic yields one appointment through that selection order, Meeting Assistant SHALL copy the appointment title to the meeting note and copy the appointment agenda and scheduled end time to the assistant-context frontmatter.
|
||||
|
||||
When more than one appointment exists in the applicable in-progress or upcoming selection group, Meeting Assistant SHALL leave standalone metadata unselected.
|
||||
|
||||
Meeting Assistant SHALL exclude canceled Outlook appointments from current Teams appointment metadata lookup.
|
||||
|
||||
Meeting Assistant SHALL copy the appointment attendees to the meeting note only when the raw appointment attendee count is less than or equal to the configured `Recording:MaxMetadataAttendeeImportCount`. The default maximum SHALL be 30 attendees.
|
||||
|
||||
The agenda SHALL be extracted from the appointment body content before the Teams join separator or Teams join text.
|
||||
|
||||
#### Scenario: Current Teams appointment enriches meeting artifacts
|
||||
- **WHEN** a Windows build starts a meeting while Outlook Classic selects exactly one Teams appointment through the standalone selection order
|
||||
- **THEN** Meeting Assistant uses the appointment subject as the meeting title
|
||||
- **AND** writes the appointment attendees into meeting note frontmatter when the raw attendee count is within the configured import limit
|
||||
- **AND** writes the appointment agenda into assistant-context frontmatter
|
||||
- **AND** writes the appointment end time as `scheduled_end` into assistant-context frontmatter
|
||||
|
||||
#### Scenario: Meeting starting within five minutes enriches an early recording
|
||||
- **GIVEN** no suitable Teams appointment is already in progress
|
||||
- **AND** exactly one Teams appointment starts one minute after the recording start time
|
||||
- **WHEN** a Windows build starts the meeting recording
|
||||
- **THEN** Meeting Assistant selects that upcoming appointment's metadata
|
||||
|
||||
#### Scenario: In-progress meeting takes priority over an upcoming meeting
|
||||
- **GIVEN** exactly one Teams appointment is in progress with at least five minutes remaining
|
||||
- **AND** another Teams appointment starts within five minutes after the recording start time
|
||||
- **WHEN** a Windows build starts the meeting recording
|
||||
- **THEN** Meeting Assistant selects the in-progress appointment's metadata
|
||||
|
||||
#### Scenario: Canceled appointment is ignored during metadata lookup
|
||||
- **GIVEN** Outlook Classic exposes one canceled suitable Teams appointment
|
||||
- **AND** Outlook Classic exposes one active suitable Teams appointment at the same time
|
||||
- **WHEN** a Windows build starts a meeting
|
||||
- **THEN** Meeting Assistant selects the active appointment metadata
|
||||
|
||||
#### Scenario: Oversized attendee list is not imported
|
||||
- **GIVEN** the configured metadata attendee import limit is 30
|
||||
- **WHEN** a Windows build starts a meeting while Outlook Classic exposes exactly one suitable Teams appointment with 31 attendees
|
||||
- **THEN** Meeting Assistant uses the appointment subject as the meeting title
|
||||
- **AND** does not write the appointment attendees into meeting note frontmatter
|
||||
- **AND** writes the appointment agenda into assistant-context frontmatter
|
||||
- **AND** writes the appointment end time as `scheduled_end` into assistant-context frontmatter
|
||||
|
||||
#### Scenario: Outlook is unavailable or metadata lookup is ambiguous
|
||||
- **WHEN** Outlook Classic is unavailable or more than one Teams appointment exists in the applicable in-progress or upcoming selection group
|
||||
- **THEN** Meeting Assistant starts the recording with the default generated meeting title and empty agenda
|
||||
- **AND** omits `scheduled_end` from assistant-context frontmatter
|
||||
|
||||
### Requirement: Outlook Teams meetings can prompt recording start
|
||||
Meeting Assistant SHALL enable scheduled Outlook Classic calendar checks for recording-start prompts by default.
|
||||
|
||||
When scheduled recording prompts are enabled on Windows, Meeting Assistant SHALL periodically read the user's Outlook Classic calendar appointments for the current local day through COM into an in-memory cache.
|
||||
|
||||
Meeting Assistant SHALL default the Outlook calendar sync interval to 30 minutes when scheduled recording prompts are enabled.
|
||||
|
||||
Meeting Assistant SHALL schedule recording-start prompts from the cached calendar appointments rather than querying Outlook for each prompt.
|
||||
|
||||
Meeting Assistant SHALL consider Teams appointments from Outlook calendar data as initial prompt candidates. The detection MAY be extended later for other meeting providers.
|
||||
|
||||
Meeting Assistant SHALL exclude canceled Outlook appointments from recording-start prompt candidates.
|
||||
|
||||
When a Teams appointment reaches its scheduled start window, Meeting Assistant SHALL show a native Windows app notification asking whether to record the meeting, with affirmative and negative actions.
|
||||
|
||||
When a recording is active and the cached appointment has metadata, the recording-start notification SHALL additionally offer an `Add metadata to current meeting` action after the affirmative and negative actions. The native Windows notification renderer MAY choose the physical button layout.
|
||||
|
||||
On Windows, the recording-start notification SHALL request reminder-style toast behavior and remain actionable for 5 minutes.
|
||||
|
||||
Meeting Assistant SHALL prompt at most once per calendar appointment during a local day, regardless of whether the user accepts, declines, ignores, or attaches metadata from the notification.
|
||||
|
||||
If the user accepts the recording prompt while no recording is active, Meeting Assistant SHALL start a new recording normally.
|
||||
|
||||
If the user accepts the recording prompt while another recording is active, Meeting Assistant SHALL stop the active recording normally and then start the prompted meeting recording.
|
||||
|
||||
When stopping an active recording for an accepted prompt, Meeting Assistant SHALL use the normal stop path so empty or too-short recordings are removed according to existing settings and other completed recordings continue normal transcription, speaker recognition, and summary processing.
|
||||
|
||||
When the user accepts a recording prompt, Meeting Assistant SHALL start the recording with the accepted cached appointment's metadata and SHALL NOT perform a separate current-meeting metadata lookup for that prompted start.
|
||||
|
||||
Prompted-start metadata SHALL include the accepted appointment title, attendees, agenda, and scheduled end when those values are available from the cached appointment.
|
||||
|
||||
Prompted starts SHALL run the normal meeting workflow `created` rules before applying the accepted appointment metadata.
|
||||
|
||||
After `created` rules run, prompted starts SHALL apply the accepted appointment metadata and then run the normal `collecting metadata` to `transcribing` state-transition workflow rules with that metadata available in the meeting note.
|
||||
|
||||
If the user chooses `Add metadata to current meeting` while that recording is still active, Meeting Assistant SHALL apply the exact cached appointment's title, eligible attendees, agenda, and scheduled end to the active meeting without stopping or starting recording.
|
||||
|
||||
Attaching prompted metadata SHALL use the normal attendee canonicalization, attendee import limit, and `attendee_added` workflow transformations, SHALL preserve the meeting note body, and SHALL refresh meeting metadata in the meeting note, assistant context, and transcript artifacts.
|
||||
|
||||
Explicitly attached prompted metadata SHALL take precedence over any standalone Outlook metadata lookup still running for that recording.
|
||||
|
||||
If no recording is active when the attach action is handled, Meeting Assistant SHALL leave meeting artifacts and recording state unchanged.
|
||||
|
||||
#### Scenario: Teams meeting start prompts the user
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** Meeting Assistant has synced Outlook Classic Teams appointments for today
|
||||
- **WHEN** the appointment reaches its scheduled start window
|
||||
- **THEN** Meeting Assistant shows a native Windows app notification asking whether to record the meeting
|
||||
- **AND** the notification remains actionable for 5 minutes
|
||||
- **AND** marks that appointment as prompted for the day
|
||||
|
||||
#### Scenario: Active recording prompt offers metadata attachment
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** a meeting recording is active
|
||||
- **AND** the due cached appointment has metadata
|
||||
- **WHEN** Meeting Assistant shows the appointment's recording-start notification
|
||||
- **THEN** the notification includes `Yes`, `No`, and `Add metadata to current meeting` actions
|
||||
- **AND** orders the metadata action after `Yes` and `No`
|
||||
|
||||
#### Scenario: Idle prompt omits metadata attachment
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** Meeting Assistant shows an appointment's recording-start notification
|
||||
- **THEN** the notification includes the existing affirmative and negative actions
|
||||
- **AND** does not include `Add metadata to current meeting`
|
||||
|
||||
#### Scenario: Canceled Teams meeting does not prompt recording
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** Meeting Assistant has synced a canceled Outlook Classic Teams appointment for today
|
||||
- **WHEN** the canceled appointment reaches its scheduled start window
|
||||
- **THEN** Meeting Assistant does not show a recording prompt for that appointment
|
||||
|
||||
#### Scenario: Back-to-back cached Teams meetings prompt without another Outlook sync
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** Meeting Assistant has synced two Teams appointments for today that start ten minutes apart
|
||||
- **WHEN** each appointment reaches its scheduled start window
|
||||
- **THEN** Meeting Assistant shows a recording prompt for each appointment
|
||||
- **AND** does not require another Outlook calendar sync between the prompts
|
||||
|
||||
#### Scenario: User accepts prompt while idle
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the user accepts a Teams meeting recording prompt
|
||||
- **THEN** Meeting Assistant starts recording normally
|
||||
|
||||
#### Scenario: User accepts prompt while already recording
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** a meeting recording is active
|
||||
- **WHEN** the user accepts a Teams meeting recording prompt
|
||||
- **THEN** Meeting Assistant stops the active recording normally
|
||||
- **AND** starts a new recording normally after the stop request
|
||||
|
||||
#### Scenario: Accepted prompt supplies meeting metadata
|
||||
- **GIVEN** scheduled Outlook recording prompts are enabled
|
||||
- **AND** Meeting Assistant has cached two current Teams appointments with different metadata
|
||||
- **WHEN** the user accepts the recording prompt for one appointment
|
||||
- **THEN** Meeting Assistant starts the recording with the accepted appointment's metadata
|
||||
- **AND** does not perform a standalone current-meeting metadata lookup for that recording
|
||||
- **AND** runs `created` workflow rules before writing the cached appointment metadata
|
||||
- **AND** runs `collecting metadata` to `transcribing` workflow rules after writing the cached appointment metadata
|
||||
|
||||
#### Scenario: Prompted metadata is attached to the active meeting
|
||||
- **GIVEN** a meeting recording is active without calendar metadata
|
||||
- **AND** two concurrent appointment notifications carry different cached metadata
|
||||
- **WHEN** the user chooses `Add metadata to current meeting` on one notification
|
||||
- **THEN** Meeting Assistant applies only that notification's appointment metadata to the active meeting
|
||||
- **AND** keeps the current recording and transcription running
|
||||
- **AND** does not start a new recording
|
||||
|
||||
#### Scenario: Explicit attachment wins over delayed lookup
|
||||
- **GIVEN** standalone Outlook metadata lookup is still running for an active recording
|
||||
- **WHEN** the user attaches metadata from an appointment notification
|
||||
- **AND** the standalone lookup completes later with different metadata
|
||||
- **THEN** the prompted appointment metadata remains on the active meeting artifacts
|
||||
|
||||
#### Scenario: Attach action becomes stale
|
||||
- **GIVEN** a recording-start notification offered `Add metadata to current meeting`
|
||||
- **AND** the active recording stops before the action is handled
|
||||
- **WHEN** the user activates the metadata action
|
||||
- **THEN** Meeting Assistant does not change completed meeting artifacts
|
||||
- **AND** does not start a recording
|
||||
|
||||
#### Scenario: Prompt is disabled
|
||||
- **GIVEN** scheduled Outlook recording prompts are disabled
|
||||
- **WHEN** a Teams appointment reaches its scheduled start
|
||||
- **THEN** Meeting Assistant does not query Outlook for recording prompt candidates
|
||||
- **AND** does not show a recording prompt
|
||||
@@ -1,24 +0,0 @@
|
||||
## 1. Calendar Selection Contract
|
||||
|
||||
- [x] 1.1 Verify the standalone Outlook selector covers a meeting started one minute early and remains ambiguous for concurrent upcoming meetings.
|
||||
|
||||
## 2. Prompt Attach Action
|
||||
|
||||
- [x] 2.1 Add a failing scheduler behavior test that an active-recording prompt exposes metadata attachment and routes the exact appointment metadata without stop/start.
|
||||
- [x] 2.2 Implement the prompt request/response and recording-controller attach contract to pass the scheduler behavior test.
|
||||
- [x] 2.3 Add the conditional `Add metadata to current meeting` Windows toast action and activation mapping while preserving Yes/No behavior.
|
||||
|
||||
## 3. Active Meeting Metadata
|
||||
|
||||
- [x] 3.1 Add a failing coordinator behavior test for attaching title, eligible transformed attendees, agenda, and scheduled end to active meeting, assistant-context, and transcript artifacts without interrupting recording.
|
||||
- [x] 3.2 Implement synchronized active-run metadata attachment through the shared metadata application path.
|
||||
- [x] 3.3 Add a failing race behavior test proving explicit prompted metadata wins over a delayed standalone lookup, then implement the run-level explicit-assignment guard.
|
||||
- [x] 3.4 Add a stale-action behavior test proving attachment is a no-op after capture stops.
|
||||
- [x] 3.5 Add a failing attachment behavior test proving a persistence failure does not suppress the fallback Outlook lookup, then mark metadata explicit only after successful persistence.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run focused calendar and recording coordinator tests, then the full test suite and Windows-target build.
|
||||
- [x] 4.2 Run `openspec validate attach-calendar-metadata-to-active-meeting --strict` and verify the change task list is complete.
|
||||
- [x] 4.3 Perform the required DRY, SOLID, and KISS refactoring passes with tests after any changes.
|
||||
- [x] 4.4 Verify the notification and active-recording behavior through the safest available operational surface without interrupting live meeting work.
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-04
|
||||
@@ -1,45 +0,0 @@
|
||||
## Context
|
||||
|
||||
The tray-menu builder currently returns a flat list of semantic actions, while the Windows renderer infers separators from item indexes and the Exit action. During an active recording, the normal stop action is added after the microphone submenu and uses a long implementation-oriented label. This makes the primary meeting-completion action look equivalent to cancel, profile switching, and device selection.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Give normal meeting completion the concise label `Finish meeting`.
|
||||
- Make that action the only item in the section immediately below `Open agent` while recording.
|
||||
- Keep fine-grained recording controls in a distinct following section.
|
||||
- Make section boundaries observable in platform-independent menu behavior tests.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Change what normal stop, abort, profile switching, or microphone selection does.
|
||||
- Change idle-menu actions, hotkeys, endpoints, or recording state transitions.
|
||||
- Add icons, confirmation prompts, or nested submenus.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Represent section starts in the menu model
|
||||
|
||||
Add a section-start flag to `MeetingTaskbarMenuItem`. The Windows renderer will insert a separator before items carrying the flag instead of deriving layout from array indexes and action types.
|
||||
|
||||
This keeps layout intent in the platform-independent builder where behavior tests can observe it. Keeping another renderer-only special case was rejected because it would leave the requested prominence untestable without Windows UI automation.
|
||||
|
||||
### Build prioritized and fine-grained controls as separate groups
|
||||
|
||||
While recording, the builder will add `Open agent`, then `Finish meeting` as a new section, then collect microphone, cancel/discard, and profile-switch actions into a fine-grained group whose first item starts another section. Exit remains the final section.
|
||||
|
||||
The action continues to use the existing normal-stop command so transcription, speaker processing, OCR, and summarization semantics do not change.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **A section flag could produce adjacent separators if assigned carelessly** → The builder marks only the first item of each non-empty group, and the renderer follows those explicit starts.
|
||||
- **Menu ordering changes while recording** → Limit reordering to the active-recording state; idle and processing actions retain their existing relative order.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No configuration or data migration is required. Deploying the updated executable changes only tray-menu presentation. Rollback restores the previous label and grouping.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -1,25 +0,0 @@
|
||||
## Why
|
||||
|
||||
The active-recording tray menu labels its most important completion action as the verbose `Stop meeting recording and transcribe` and groups it with rarely used controls. Finishing a meeting should be immediately recognizable and visually prioritized during normal use.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Rename the active-recording stop action to `Finish meeting` without changing its normal stop, transcription, or summary behavior.
|
||||
- Place `Finish meeting` by itself in the section immediately below `Open agent`.
|
||||
- Place microphone selection, cancel/discard, and profile-switch controls in a separate lower-priority section.
|
||||
- Represent tray-menu section boundaries explicitly so ordering and prominence are behavior-testable.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-recording`: Prioritize the normal meeting completion action in the Windows tray menu with a concise label and dedicated section.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects the platform-independent tray-menu model/builder, Windows tray-menu rendering, and taskbar behavior tests.
|
||||
- Does not change recording lifecycle semantics, hotkeys, endpoints, or generated meeting artifacts.
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Windows taskbar icon controls recording
|
||||
Meeting Assistant SHALL show a Windows taskbar notification icon when running on Windows.
|
||||
|
||||
The taskbar icon SHALL indicate whether the newest meeting process is idle, actively recording, or post-recording processing/summarizing.
|
||||
|
||||
When a new meeting is actively recording while an older stopped meeting is still transcribing, recognizing speakers, or summarizing, the taskbar icon SHALL show the new active recording state.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose recording controls based on the current state and configured launch profiles.
|
||||
|
||||
The taskbar icon right-click menu SHALL expose an Exit action in every recording state.
|
||||
|
||||
When Meeting Assistant is idle or only processing older stopped meetings, the menu SHALL allow starting a meeting recording for each configured launch profile.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow stopping the recording and continuing transcription/summary generation.
|
||||
|
||||
During an active recording, the normal stop action SHALL be labeled `Finish meeting` and SHALL be the only action in a dedicated menu section immediately below the `Open agent` section.
|
||||
|
||||
During an active recording, microphone selection, cancel/discard, and profile-switch actions SHALL appear in a separate fine-grained controls section below `Finish meeting`.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow canceling the recording and discarding that run's artifacts.
|
||||
|
||||
When a meeting is actively recording, the menu SHALL allow switching to each configured launch profile other than the current active profile.
|
||||
|
||||
Selecting Exit while Meeting Assistant is idle SHALL stop the application without an additional confirmation prompt.
|
||||
|
||||
Selecting Exit while Meeting Assistant is recording, transcribing, recognizing speakers, or summarizing SHALL show a confirmation dialog before stopping the application.
|
||||
|
||||
#### Scenario: Idle tray menu can start configured profiles
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** no meeting recording is active
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers start recording actions for `default` and `english`
|
||||
|
||||
#### Scenario: Recording tray menu prioritizes finishing the meeting
|
||||
- **GIVEN** launch profiles `default` and `english` are configured
|
||||
- **AND** a meeting is actively recording with profile `default`
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** `Finish meeting` is the only action in the section immediately below `Open agent`
|
||||
- **AND** microphone selection, cancel/discard, and switching to `english` appear in a separate following section
|
||||
- **AND** the menu does not offer switching to `default`
|
||||
|
||||
#### Scenario: Active recording has priority over older summarizing runs
|
||||
- **GIVEN** an older meeting is still summarizing
|
||||
- **WHEN** a newer meeting is actively recording
|
||||
- **THEN** the taskbar icon indicates recording
|
||||
|
||||
#### Scenario: Tray menu always exposes Exit
|
||||
- **GIVEN** Meeting Assistant is running
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it offers an Exit action
|
||||
|
||||
#### Scenario: Idle Exit stops immediately
|
||||
- **GIVEN** no recording, transcription, speaker recognition, or summary work is running
|
||||
- **WHEN** the user selects Exit from the taskbar menu
|
||||
- **THEN** Meeting Assistant stops the application without an additional confirmation prompt
|
||||
|
||||
#### Scenario: In-progress Exit asks for confirmation
|
||||
- **GIVEN** Meeting Assistant is recording, transcribing, recognizing speakers, or summarizing
|
||||
- **WHEN** the user selects Exit from the taskbar menu
|
||||
- **THEN** Meeting Assistant asks for confirmation before stopping the application
|
||||
@@ -1,15 +0,0 @@
|
||||
## 1. Tray Menu Behavior
|
||||
|
||||
- [x] 1.1 Add a failing behavior test proving that an active recording labels the normal stop action `Finish meeting`, places it alone immediately below `Open agent`, and keeps fine-grained controls in the following section.
|
||||
- [x] 1.2 Add explicit section metadata to the tray-menu model, reorder the active-recording actions, and render separators from that metadata.
|
||||
|
||||
## 2. Verification
|
||||
|
||||
- [x] 2.1 Review the touched menu builder and renderer for DRYness, SOLID design, and simplicity while preserving behavior.
|
||||
- [x] 2.2 Run focused taskbar-menu tests, the Windows application build, the full solution tests, and strict OpenSpec validation.
|
||||
|
||||
## 3. Refactor Follow-up
|
||||
|
||||
- [x] 3.1 Lock down idle section boundaries and active-recording layout when no microphone is available.
|
||||
- [x] 3.2 Remove the tray-menu section helper's hidden input mutation without changing rendered behavior.
|
||||
- [x] 3.3 Run focused and full verification, then validate the OpenSpec change strictly.
|
||||
Reference in New Issue
Block a user