Public Access
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2af3b5bf83 |
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using MeetingAssistant.Calendar;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Recording;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -23,94 +22,6 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
Assert.Equal(0, harness.Recorder.StopCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptStartsRecordingWithPromptedMeetingMetadata()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var selectedMetadata = new MeetingMetadata(
|
||||
"Selected planning",
|
||||
["Ada"],
|
||||
"Selected agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:30:00+00:00"));
|
||||
var otherMetadata = new MeetingMetadata(
|
||||
"Other planning",
|
||||
["Grace"],
|
||||
"Other agenda",
|
||||
DateTimeOffset.Parse("2026-06-03T10:40:00+00:00"));
|
||||
var selectedMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-selected",
|
||||
subject: "Selected planning",
|
||||
metadata: selectedMetadata);
|
||||
var otherMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-other",
|
||||
subject: "Other planning",
|
||||
startOffset: TimeSpan.FromMinutes(40),
|
||||
metadata: otherMetadata);
|
||||
harness.Provider.Meetings = [selectedMeeting, otherMeeting];
|
||||
|
||||
await SyncThenPromptAsync(harness, selectedMeeting);
|
||||
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Same(selectedMetadata, harness.Recorder.StartMetadata.Single());
|
||||
Assert.Equal(1, harness.Recorder.PromptStartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptWithoutMetadataStillUsesPromptStartPath()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var meeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-without-metadata",
|
||||
subject: "No metadata",
|
||||
metadata: null);
|
||||
harness.Provider.Meetings = [meeting];
|
||||
|
||||
await SyncThenPromptAsync(harness, meeting);
|
||||
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Null(harness.Recorder.StartMetadata.Single());
|
||||
Assert.Equal(1, harness.Recorder.PromptStartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptingDisplayedPromptsOutOfOrderUsesAcceptedPromptMetadata()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false, 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.Record);
|
||||
|
||||
Assert.Equal([firstMeeting, secondMeeting], harness.PromptService.PromptedMeetings);
|
||||
Assert.Equal(1, harness.Recorder.StartCount);
|
||||
Assert.Same(secondMetadata, harness.Recorder.StartMetadata.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcceptedPromptStopsActiveRecordingBeforeStartingNewRecording()
|
||||
{
|
||||
@@ -123,25 +34,6 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
Assert.Equal(["stop", "start"], harness.Recorder.Commands);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanceledCachedMeetingDoesNotPromptRecording()
|
||||
{
|
||||
var harness = CreateHarness(isRecording: false);
|
||||
var canceledMeeting = CreateMeeting(
|
||||
harness.Clock,
|
||||
id: "teams-canceled",
|
||||
subject: "Canceled project sync",
|
||||
isCanceled: true);
|
||||
harness.Provider.Meetings = [canceledMeeting];
|
||||
|
||||
await harness.Scheduler.SyncOnceAsync(CancellationToken.None);
|
||||
harness.Clock.Now = canceledMeeting.Start;
|
||||
await harness.Scheduler.CheckDuePromptsAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(harness.PromptService.PromptedMeetings);
|
||||
Assert.Equal(0, harness.Recorder.StartCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledCalendarPromptsDoNotQueryCalendar()
|
||||
{
|
||||
@@ -223,28 +115,23 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
string id = "teams-1",
|
||||
string subject = "Project sync",
|
||||
TimeSpan? startOffset = null,
|
||||
TimeSpan? duration = null,
|
||||
MeetingMetadata? metadata = null,
|
||||
bool isCanceled = false)
|
||||
TimeSpan? duration = null)
|
||||
{
|
||||
var start = clock.Now.Add(startOffset ?? TimeSpan.FromMinutes(30));
|
||||
return new CalendarMeeting(
|
||||
id,
|
||||
subject,
|
||||
start,
|
||||
start.Add(duration ?? TimeSpan.FromMinutes(30)),
|
||||
metadata,
|
||||
isCanceled);
|
||||
start.Add(duration ?? TimeSpan.FromMinutes(30)));
|
||||
}
|
||||
|
||||
private static SchedulerHarness CreateHarness(
|
||||
bool isRecording = false,
|
||||
bool enabled = true,
|
||||
bool autoAcceptPrompts = true)
|
||||
bool enabled = true)
|
||||
{
|
||||
var clock = new ManualCalendarPromptClock(new DateTimeOffset(2026, 6, 3, 9, 30, 0, TimeSpan.Zero));
|
||||
var provider = new RecordingCalendarMeetingProvider([]);
|
||||
var promptService = new CapturingMeetingStartPromptService(autoAcceptPrompts);
|
||||
var promptService = new AcceptingMeetingStartPromptService();
|
||||
var recorder = new RecordingPromptRecorder(isRecording);
|
||||
var scheduler = new CalendarRecordingPromptScheduler(
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
@@ -268,7 +155,7 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
private sealed record SchedulerHarness(
|
||||
CalendarRecordingPromptScheduler Scheduler,
|
||||
RecordingCalendarMeetingProvider Provider,
|
||||
CapturingMeetingStartPromptService PromptService,
|
||||
AcceptingMeetingStartPromptService PromptService,
|
||||
RecordingPromptRecorder Recorder,
|
||||
ManualCalendarPromptClock Clock);
|
||||
|
||||
@@ -292,16 +179,8 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingStartPromptService : IMeetingStartPromptService
|
||||
private sealed class AcceptingMeetingStartPromptService : IMeetingStartPromptService
|
||||
{
|
||||
private readonly bool autoAccept;
|
||||
private readonly List<PendingPrompt> pendingPrompts = [];
|
||||
|
||||
public CapturingMeetingStartPromptService(bool autoAccept)
|
||||
{
|
||||
this.autoAccept = autoAccept;
|
||||
}
|
||||
|
||||
public List<CalendarMeeting> PromptedMeetings { get; } = [];
|
||||
|
||||
public async Task ShowPromptAsync(
|
||||
@@ -310,24 +189,8 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PromptedMeetings.Add(request.Meeting);
|
||||
pendingPrompts.Add(new PendingPrompt(request.Meeting, handleResponseAsync));
|
||||
if (autoAccept)
|
||||
{
|
||||
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
|
||||
}
|
||||
await handleResponseAsync(MeetingStartPromptResponse.Record, cancellationToken);
|
||||
}
|
||||
|
||||
public Task RespondAsync(
|
||||
CalendarMeeting meeting,
|
||||
MeetingStartPromptResponse response)
|
||||
{
|
||||
var prompt = pendingPrompts.Single(pending => ReferenceEquals(pending.Meeting, meeting));
|
||||
return prompt.HandleResponseAsync(response, CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(
|
||||
CalendarMeeting Meeting,
|
||||
Func<MeetingStartPromptResponse, CancellationToken, Task> HandleResponseAsync);
|
||||
}
|
||||
|
||||
private sealed class RecordingPromptRecorder : IMeetingPromptRecordingController
|
||||
@@ -343,35 +206,16 @@ public sealed class CalendarRecordingPromptSchedulerTests
|
||||
|
||||
public int StopCount { get; private set; }
|
||||
|
||||
public int PromptStartCount { get; private set; }
|
||||
|
||||
public List<string> Commands { get; } = [];
|
||||
|
||||
public List<MeetingMetadata?> StartMetadata { get; } = [];
|
||||
|
||||
public Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return StartRecordingAsync(null);
|
||||
}
|
||||
|
||||
private Task<RecordingStatus> StartRecordingAsync(
|
||||
MeetingMetadata? metadata)
|
||||
{
|
||||
StartCount++;
|
||||
Commands.Add("start");
|
||||
StartMetadata.Add(metadata);
|
||||
CurrentStatus = Status(isRecording: true);
|
||||
return Task.FromResult(CurrentStatus);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StartFromPromptAsync(
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PromptStartCount++;
|
||||
return StartRecordingAsync(metadata);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
StopCount++;
|
||||
|
||||
@@ -1,253 +1,103 @@
|
||||
using MeetingAssistant.Summary;
|
||||
using Microsoft.Extensions.AI;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class LiteLlmResponsesChatClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ClientAssemblesStreamedTextResponseWithMetadataAndUsage()
|
||||
public void ParserIgnoresReasoningItemsWithNullStatusAndReadsText()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
const string json = """
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
data: {"type":"response.created","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"in_progress","store":false},"sequence_number":0}
|
||||
"id": "resp_test",
|
||||
"created_at": 1779147100,
|
||||
"model": "gpt-5.5-2026-04-23",
|
||||
"output": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"status": null
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "OK"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_stream","type":"message","status":"in_progress","content":[],"role":"assistant"},"sequence_number":1}
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
|
||||
data: {"type":"response.content_part.added","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":""},"sequence_number":2}
|
||||
|
||||
data: {"type":"response.output_text.delta","item_id":"msg_stream","output_index":0,"content_index":0,"delta":"Streamed OK","sequence_number":3}
|
||||
|
||||
data: {"type":"response.output_text.done","item_id":"msg_stream","output_index":0,"content_index":0,"text":"Streamed OK","sequence_number":4}
|
||||
|
||||
data: {"type":"response.content_part.done","item_id":"msg_stream","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"text":"Streamed OK"},"sequence_number":5}
|
||||
|
||||
data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_stream","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Streamed OK"}],"role":"assistant"},"sequence_number":6}
|
||||
|
||||
data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}},"sequence_number":7}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"text/event-stream")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
|
||||
|
||||
Assert.Equal("Streamed OK", response.Text);
|
||||
Assert.Equal("resp_stream", response.ResponseId);
|
||||
Assert.Equal("gpt-5.5", response.ModelId);
|
||||
Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1779147100), response.CreatedAt);
|
||||
Assert.NotNull(response.Usage);
|
||||
Assert.Equal(12, response.Usage.InputTokenCount);
|
||||
Assert.Equal(3, response.Usage.OutputTokenCount);
|
||||
Assert.Equal(15, response.Usage.TotalTokenCount);
|
||||
Assert.Equal("OK", response.Text);
|
||||
Assert.Equal("resp_test", response.ResponseId);
|
||||
Assert.Equal("gpt-5.5-2026-04-23", response.ModelId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientAssemblesStreamedFunctionCall()
|
||||
public void ParserReadsFunctionCalls()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
const string json = """
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_stream","name":"write_summary","arguments":"{\"markdown\":\"# Summary\\nDone\"}","status":"completed"}}
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "write_summary",
|
||||
"arguments": "{\"markdown\":\"# Summary\\nDone\"}",
|
||||
"status": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
data: {"type":"response.completed","response":{"id":"resp_stream","model":"gpt-5.5","output":[]}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"text/event-stream")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
var call = Assert.IsType<FunctionCallContent>(Assert.Single(response.Messages[0].Contents));
|
||||
|
||||
Assert.Equal("call_stream", call.CallId);
|
||||
Assert.Equal("call_1", call.CallId);
|
||||
Assert.Equal("write_summary", call.Name);
|
||||
Assert.NotNull(call.Arguments);
|
||||
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]?.ToString());
|
||||
Assert.Equal("# Summary\nDone", call.Arguments["markdown"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientUsesNonStreamingResponsesWhenConfigured()
|
||||
public void ParserReadsUsage()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
const string json = """
|
||||
{
|
||||
"usage": {
|
||||
"input_tokens": 123,
|
||||
"output_tokens": 45,
|
||||
"total_tokens": 168
|
||||
},
|
||||
"output": [
|
||||
{
|
||||
"id": "resp_nonstream",
|
||||
"created_at": 1779147100,
|
||||
"model": "gpt-5.5",
|
||||
"object": "response",
|
||||
"output": [
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"id": "msg_nonstream",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"annotations": [],
|
||||
"text": "Non-streamed OK"
|
||||
}
|
||||
],
|
||||
"role": "assistant"
|
||||
"type": "output_text",
|
||||
"text": "OK"
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"status": "completed",
|
||||
"store": false,
|
||||
"usage": {
|
||||
"input_tokens": 12,
|
||||
"output_tokens": 4,
|
||||
"total_tokens": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0, useStreaming: false);
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "reply")]);
|
||||
var response = LiteLlmResponsesChatClient.ParseResponseJson(json);
|
||||
|
||||
Assert.Equal("Non-streamed OK", response.Text);
|
||||
Assert.Contains("\"stream\":false", Assert.Single(handler.RequestBodies));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientSendsChatMessagesAsResponsesMessageItems()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||
|
||||
await client.GetResponseAsync(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "write summary"),
|
||||
new ChatMessage(ChatRole.Assistant, "I will inspect the meeting.")
|
||||
]);
|
||||
|
||||
using var request = JsonDocument.Parse(Assert.Single(handler.RequestBodies));
|
||||
var inputItems = request.RootElement.GetProperty("input").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, inputItems.Length);
|
||||
Assert.All(inputItems, item => Assert.Equal("message", item.GetProperty("type").GetString()));
|
||||
Assert.Equal("user", inputItems[0].GetProperty("role").GetString());
|
||||
Assert.Equal(
|
||||
"input_text",
|
||||
Assert.Single(inputItems[0].GetProperty("content").EnumerateArray())
|
||||
.GetProperty("type")
|
||||
.GetString());
|
||||
Assert.Equal("assistant", inputItems[1].GetProperty("role").GetString());
|
||||
Assert.Equal(
|
||||
"output_text",
|
||||
Assert.Single(inputItems[1].GetProperty("content").EnumerateArray())
|
||||
.GetProperty("type")
|
||||
.GetString());
|
||||
Assert.DoesNotContain("\"type\":\"unknown\"", handler.RequestBodies[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentLoopReturnsMalformedFunctionArgumentsWithoutInvokingTool()
|
||||
{
|
||||
var responses = new Queue<HttpResponseMessage>(
|
||||
[
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_invalid","name":"write_summary","arguments":"{not-json","status":"completed"}}
|
||||
|
||||
data: {"type":"response.completed","response":{"id":"resp_invalid","model":"gpt-5.5","output":[]}}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"text/event-stream")
|
||||
},
|
||||
CreateStreamedTextResponse("Recovered.")
|
||||
]);
|
||||
var handler = new RecordingHttpMessageHandler(_ => responses.Dequeue());
|
||||
var toolInvoked = false;
|
||||
var tool = AIFunctionFactory.Create(
|
||||
(string markdown) =>
|
||||
{
|
||||
toolInvoked = true;
|
||||
return markdown;
|
||||
},
|
||||
"write_summary",
|
||||
"Writes a summary.");
|
||||
using var innerClient = CreateClient(handler, reconnectionAttempts: 0);
|
||||
using var functionClient = innerClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(
|
||||
loggerFactory: null,
|
||||
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||
.Build();
|
||||
|
||||
var response = await functionClient.GetResponseAsync(
|
||||
[new ChatMessage(ChatRole.User, "write summary")],
|
||||
new ChatOptions { Tools = [tool] });
|
||||
|
||||
Assert.False(toolInvoked);
|
||||
Assert.Equal("Recovered.", response.Text);
|
||||
Assert.Equal(2, handler.RequestBodies.Count);
|
||||
Assert.Contains("invalid_tool_arguments", handler.RequestBodies[1]);
|
||||
Assert.Contains("call_invalid", handler.RequestBodies[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientReportsVisibleReasoningSummariesSeparatelyFromResponseText()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
CreateStreamedTextResponse(
|
||||
"Done.",
|
||||
"Checked the configured rules file.",
|
||||
"Prepared a targeted update."));
|
||||
var reasoningSummaries = new List<string>();
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
reasoningSummaryChanged: reasoningSummaries.Add);
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.Equal(
|
||||
[
|
||||
"Checked the configured rules file.",
|
||||
"Prepared a targeted update."
|
||||
],
|
||||
reasoningSummaries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClientDoesNotFailResponseWhenReasoningSummaryCallbackFails()
|
||||
{
|
||||
var handler = new SequencedHttpMessageHandler(
|
||||
CreateStreamedTextResponse("Done.", "Checked the configured rules file."));
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
reconnectionAttempts: 0,
|
||||
reasoningSummaryChanged: _ => throw new InvalidOperationException("UI failed"));
|
||||
|
||||
var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, "update rules")]);
|
||||
|
||||
Assert.Equal("Done.", response.Text);
|
||||
Assert.NotNull(response.Usage);
|
||||
Assert.Equal(123, response.Usage.InputTokenCount);
|
||||
Assert.Equal(45, response.Usage.OutputTokenCount);
|
||||
Assert.Equal(168, response.Usage.TotalTokenCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -258,7 +108,24 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
{
|
||||
Content = new StringContent("Internal Server Error")
|
||||
},
|
||||
CreateStreamedTextResponse("Done."));
|
||||
new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
var retryCount = 0;
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 1, retrying: () => retryCount++);
|
||||
|
||||
@@ -289,7 +156,24 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
[Fact]
|
||||
public async Task ClientSendsUserInitiatorOnceThenAgentInitiator()
|
||||
{
|
||||
var handler = new RecordingHttpMessageHandler(_ => CreateStreamedTextResponse("Done."));
|
||||
var handler = new RecordingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
});
|
||||
using var client = CreateClient(handler, reconnectionAttempts: 0);
|
||||
|
||||
await client.GetResponseAsync([new ChatMessage(ChatRole.User, "write summary")]);
|
||||
@@ -321,7 +205,24 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
};
|
||||
}
|
||||
|
||||
return CreateStreamedTextResponse("Done.");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
};
|
||||
});
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
@@ -356,7 +257,24 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
};
|
||||
}
|
||||
|
||||
return CreateStreamedTextResponse("Done.");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Done."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
};
|
||||
});
|
||||
using var client = CreateClient(
|
||||
handler,
|
||||
@@ -388,9 +306,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
HttpMessageHandler handler,
|
||||
int reconnectionAttempts,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null,
|
||||
bool useStreaming = true)
|
||||
Action? retrying = null)
|
||||
{
|
||||
return new LiteLlmResponsesChatClient(
|
||||
new HttpClient(handler)
|
||||
@@ -404,50 +320,7 @@ public sealed class LiteLlmResponsesChatClientTests
|
||||
reconnectionAttempts,
|
||||
TimeSpan.Zero,
|
||||
compactionOptions,
|
||||
retrying: retrying,
|
||||
reasoningSummaryChanged: reasoningSummaryChanged,
|
||||
useStreaming: useStreaming);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateStreamedTextResponse(
|
||||
string text,
|
||||
params string[] reasoningSummaries)
|
||||
{
|
||||
var events = new List<string>();
|
||||
for (var index = 0; index < reasoningSummaries.Length; index++)
|
||||
{
|
||||
events.Add("data: " + JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "response.reasoning_summary_text.delta",
|
||||
item_id = "reasoning_stream",
|
||||
output_index = 0,
|
||||
summary_index = index,
|
||||
delta = reasoningSummaries[index]
|
||||
}));
|
||||
}
|
||||
|
||||
var outputIndex = reasoningSummaries.Length > 0 ? 1 : 0;
|
||||
events.Add("data: " + JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "response.output_text.delta",
|
||||
item_id = "msg_stream",
|
||||
output_index = outputIndex,
|
||||
content_index = 0,
|
||||
delta = text
|
||||
}));
|
||||
events.Add(
|
||||
"""data: {"type":"response.completed","response":{"id":"resp_stream","created_at":1779147100,"model":"gpt-5.5","object":"response","output":[],"parallel_tool_calls":true,"status":"completed","store":false,"usage":{"input_tokens":12,"output_tokens":3,"total_tokens":15}}}""");
|
||||
events.Add("data: [DONE]");
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
string.Join($"{Environment.NewLine}{Environment.NewLine}", events)
|
||||
+ Environment.NewLine
|
||||
+ Environment.NewLine,
|
||||
Encoding.UTF8,
|
||||
"text/event-stream")
|
||||
};
|
||||
retrying: retrying);
|
||||
}
|
||||
|
||||
private sealed class SequencedHttpMessageHandler : HttpMessageHandler
|
||||
|
||||
@@ -16,7 +16,9 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractUsesAgentEndpointAndModelWhenOcrEndpointAndModelAreBlank()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [1, 2, 3]);
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output": [
|
||||
@@ -73,7 +75,9 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractUsesScreenshotOcrEndpointAndModelWhenConfigured()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([4, 5, 6]);
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, [4, 5, 6]);
|
||||
var handler = new RecordingHandler("""{ "output_text": "OCR result" }""");
|
||||
var client = new LiteLlmScreenshotOcrClient(
|
||||
() => handler,
|
||||
@@ -112,7 +116,9 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
[Fact]
|
||||
public async Task ExtractParsesCropMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, CreatePngBytes(8, 6));
|
||||
var handler = new RecordingHandler("""
|
||||
{
|
||||
"output_text": "Slide text\n\n```json\n{ \"crop\": { \"x\": 1, \"y\": 2, \"width\": 3, \"height\": 4 } }\n```"
|
||||
@@ -144,67 +150,6 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractParsesAttendeeMetadataAndOmitsMetadataFromReturnedText()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync([1, 2, 3]);
|
||||
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);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Visible participant tiles: Ada and Grace.", result.Text);
|
||||
Assert.Equal(["Ada Lovelace", "Grace Hopper"], result.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractIgnoresMalformedAttendeesMetadataAndStillParsesCrop()
|
||||
{
|
||||
var screenshotPath = await CreateScreenshotAsync(CreatePngBytes(8, 6));
|
||||
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);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
Agent =
|
||||
{
|
||||
Key = "agent-key"
|
||||
}
|
||||
};
|
||||
|
||||
var result = await client.ExtractAsync(
|
||||
screenshotPath,
|
||||
"Extract screenshot.",
|
||||
options,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("Slide text", result.Text);
|
||||
Assert.Equal(new ScreenshotCropCoordinates(1, 2, 3, 4), result.Crop);
|
||||
Assert.Empty(result.Attendees);
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string responseBody;
|
||||
@@ -248,14 +193,6 @@ public sealed class LiteLlmScreenshotOcrClientTests
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<string> CreateScreenshotAsync(byte[] bytes)
|
||||
{
|
||||
var screenshotPath = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N") + ".png");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(screenshotPath)!);
|
||||
await File.WriteAllBytesAsync(screenshotPath, bytes);
|
||||
return screenshotPath;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -135,72 +135,6 @@ public sealed class MeetingNoteStoreTests
|
||||
Assert.Equal("User notes.", loaded.UserNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreEscapesApostrophePrefixedAttendeesBeforeWritingFrontmatter()
|
||||
{
|
||||
var (store, saved) = await SaveNoteAsync(
|
||||
title: "Escaped Attendees",
|
||||
attendees: ["'Ada Lovelace"],
|
||||
projects: [],
|
||||
userNotes: "Discuss attendee import.");
|
||||
var content = await File.ReadAllTextAsync(saved.Path);
|
||||
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
|
||||
Assert.Contains("- \"'Ada Lovelace\"", content);
|
||||
Assert.Equal(["'Ada Lovelace"], loaded.Frontmatter.Attendees);
|
||||
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Ada # platform lead")]
|
||||
[InlineData("- Ada Lovelace")]
|
||||
[InlineData("? Ada Lovelace")]
|
||||
[InlineData("{Ada: Platform}")]
|
||||
[InlineData("*Ada")]
|
||||
[InlineData("&Ada")]
|
||||
[InlineData("!Ada")]
|
||||
[InlineData("| Ada")]
|
||||
[InlineData("> Ada")]
|
||||
[InlineData("@Ada")]
|
||||
[InlineData("`Ada")]
|
||||
[InlineData("true")]
|
||||
[InlineData("null")]
|
||||
[InlineData("2026-07-08")]
|
||||
[InlineData("Ada \"The Architect\" Lovelace")]
|
||||
[InlineData("C:\\People\\Ada")]
|
||||
public async Task StoreEscapesYamlSensitiveAttendeesBeforeWritingFrontmatter(string attendee)
|
||||
{
|
||||
var (store, saved) = await SaveNoteAsync(
|
||||
title: "Escaped Attendees",
|
||||
attendees: [attendee],
|
||||
projects: [],
|
||||
userNotes: "Discuss attendee import.");
|
||||
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal([attendee], loaded.Frontmatter.Attendees);
|
||||
Assert.Equal("Discuss attendee import.", loaded.UserNotes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Planning # Q3")]
|
||||
[InlineData("- Planning")]
|
||||
[InlineData("{Planning: Q3}")]
|
||||
[InlineData("true")]
|
||||
[InlineData("2026-07-08")]
|
||||
public async Task StoreEscapesYamlSensitiveScalarAndProjectFrontmatterValues(string value)
|
||||
{
|
||||
var (store, saved) = await SaveNoteAsync(
|
||||
title: value,
|
||||
attendees: [],
|
||||
projects: [value],
|
||||
userNotes: "Discuss YAML escaping.");
|
||||
var loaded = await store.ReadAsync(saved.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal(value, loaded.Frontmatter.Title);
|
||||
Assert.Equal([value], loaded.Frontmatter.Projects);
|
||||
Assert.Equal("Discuss YAML escaping.", loaded.UserNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActionLinkEscapesSummaryFileName()
|
||||
{
|
||||
@@ -213,26 +147,6 @@ public sealed class MeetingNoteStoreTests
|
||||
link);
|
||||
}
|
||||
|
||||
private static async Task<(MarkdownMeetingNoteStore Store, MeetingNote Saved)> SaveNoteAsync(
|
||||
string title,
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlyList<string> projects,
|
||||
string userNotes)
|
||||
{
|
||||
var (vaultRoot, store) = CreateStore();
|
||||
var note = MeetingNoteTemplate.Create(
|
||||
title: title,
|
||||
attendees: attendees,
|
||||
projects: projects,
|
||||
transcriptPath: Path.Combine(vaultRoot, "Meetings", "Transcripts", "20260519-transcript.md"),
|
||||
assistantContextPath: Path.Combine(vaultRoot, "Meetings", "Assistant Context", "20260519-context.md"),
|
||||
summaryPath: Path.Combine(vaultRoot, "Meetings", "Summaries", "20260519-summary.md"),
|
||||
userNotes: userNotes);
|
||||
|
||||
var saved = await store.SaveAsync(note, CancellationToken.None);
|
||||
return (store, saved);
|
||||
}
|
||||
|
||||
private static (string VaultRoot, MarkdownMeetingNoteStore Store) CreateStore()
|
||||
{
|
||||
var vaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
|
||||
@@ -133,194 +130,6 @@ public sealed class MeetingScreenshotServiceTests
|
||||
Assert.Contains("Shared screen text", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureAddsOcrAttendeesToMeetingNoteThroughCanonicalizer()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(
|
||||
options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
},
|
||||
attendees: ["Ada Lovelace"]);
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Visible participant tiles: Ada, Grace, and Ada again.",
|
||||
attendees: ["Ada L.", "Grace Hopper", "Ada Lovelace"]);
|
||||
var canonicalizer = new MappingAttendeeCanonicalizer(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Ada L."] = "Ada Lovelace"
|
||||
});
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr,
|
||||
canonicalizer);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada Lovelace", "Grace Hopper"], meeting.Frontmatter.Attendees);
|
||||
Assert.Contains(canonicalizer.Requests, request =>
|
||||
request.SequenceEqual(["Ada Lovelace", "Ada L.", "Grace Hopper", "Ada Lovelace"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureTransformsOcrAttendeesBeforeWritingMeetingNote()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(
|
||||
options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
new CapturingScreenshotOcrClient(
|
||||
"Visible participant tile: Ada.",
|
||||
attendees: ["Ada Lovelace (Contoso)"]),
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
|
||||
await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureWritesRetryLinkWhenOcrFails()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Api.PublicBaseUrl = "http://localhost:5090";
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
new ThrowingScreenshotOcrClient("vision offline"));
|
||||
|
||||
var result = await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotId = ExtractScreenshotOcrId(context);
|
||||
Assert.Contains("_OCR failed: vision offline_", context);
|
||||
Assert.Contains("<!-- screenshot-ocr:", context);
|
||||
Assert.Contains("<!-- /screenshot-ocr:", context);
|
||||
Assert.Contains("[Retry screenshot OCR](http://localhost:5090/meetings/screenshot-ocr/retry?", context);
|
||||
Assert.Contains($"screenshotId={screenshotId}", context);
|
||||
Assert.Contains($"screenshotPath={Uri.EscapeDataString(result.ScreenshotPath)}", context);
|
||||
Assert.Contains($"assistantContextPath={Uri.EscapeDataString(fixture.Artifacts.AssistantContextPath)}", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryOcrReplacesFailureForSameScreenshot()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
});
|
||||
var ocr = new SequencedScreenshotOcrClient(
|
||||
new InvalidOperationException("vision offline"),
|
||||
new ScreenshotOcrResult("Retried OCR text", null, ["Grace Hopper"]));
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
|
||||
var result = await service.CaptureAsync(
|
||||
fixture.Artifacts,
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:10+02:00"),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
var failedContext = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
var screenshotId = ExtractScreenshotOcrId(failedContext);
|
||||
|
||||
var retry = await service.TriggerOcrRetryAsync(
|
||||
fixture.Artifacts,
|
||||
result.ScreenshotPath,
|
||||
screenshotId,
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(retry);
|
||||
Assert.Equal(result.ScreenshotPath, retry.ScreenshotPath);
|
||||
Assert.Equal(screenshotId, retry.ScreenshotId);
|
||||
Assert.Equal([result.ScreenshotPath, result.ScreenshotPath], ocr.ScreenshotPaths);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("### OCR", context);
|
||||
Assert.Contains("Retried OCR text", context);
|
||||
Assert.DoesNotContain("vision offline", context);
|
||||
Assert.DoesNotContain("Retry screenshot OCR", context);
|
||||
Assert.DoesNotContain("<!-- screenshot-ocr:", context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMeetingNoteImageEmbedsAppendsContextAndRunsOcrWithoutCropOrAttendees()
|
||||
{
|
||||
var fixture = await ScreenshotFixture.CreateAsync(
|
||||
options =>
|
||||
{
|
||||
options.Screenshots.Ocr.Enabled = true;
|
||||
},
|
||||
attendees: ["Ada Lovelace"],
|
||||
userNotes: "Discussed ![[whiteboard.png]] and .");
|
||||
var noteFolder = Path.GetDirectoryName(fixture.Artifacts.MeetingNotePath)!;
|
||||
var attachmentsFolder = Path.Combine(noteFolder, "attachments");
|
||||
Directory.CreateDirectory(attachmentsFolder);
|
||||
var whiteboardPath = Path.Combine(noteFolder, "whiteboard.png");
|
||||
var diagramPath = Path.Combine(attachmentsFolder, "diagram.png");
|
||||
await File.WriteAllBytesAsync(whiteboardPath, [1, 2, 3]);
|
||||
await File.WriteAllBytesAsync(diagramPath, [4, 5, 6]);
|
||||
var ocr = new CapturingScreenshotOcrClient(
|
||||
"Manual image OCR",
|
||||
new ScreenshotCropCoordinates(1, 1, 2, 2),
|
||||
["Grace Hopper"]);
|
||||
var service = fixture.CreateService(
|
||||
new FixedScreenshotCapture([1, 2, 3]),
|
||||
ocr);
|
||||
var originalMeetingNote = await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath);
|
||||
|
||||
var result = await service.ProcessMeetingNoteImageEmbedsAsync(
|
||||
fixture.Artifacts,
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
await service.WaitForPendingOcrAsync(fixture.Artifacts, TimeSpan.FromSeconds(5), CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.QueuedCount);
|
||||
Assert.Equal(2, ocr.CallCount);
|
||||
Assert.Equal([whiteboardPath, diagramPath], ocr.ScreenshotPaths);
|
||||
var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath);
|
||||
Assert.Contains("## Meeting Note Image", context);
|
||||
Assert.Contains("Image from meeting note.", context);
|
||||
Assert.Contains("Original embed: `![[whiteboard.png]]`", context);
|
||||
Assert.Contains("Original embed: ``", context);
|
||||
Assert.Contains("whiteboard.png", context);
|
||||
Assert.Contains("diagram.png", context);
|
||||
Assert.Contains("Manual image OCR", context);
|
||||
Assert.DoesNotContain("Cropped screenshot", context);
|
||||
Assert.Equal(originalMeetingNote, await File.ReadAllTextAsync(fixture.Artifacts.MeetingNotePath));
|
||||
var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None);
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WaitForPendingOcrWaitsForRunningScreenshotOcr()
|
||||
{
|
||||
@@ -383,13 +192,11 @@ public sealed class MeetingScreenshotServiceTests
|
||||
private ScreenshotFixture(
|
||||
MeetingAssistantOptions options,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MarkdownMeetingArtifactStore artifactStore,
|
||||
MarkdownMeetingNoteStore noteStore)
|
||||
MarkdownMeetingArtifactStore artifactStore)
|
||||
{
|
||||
Options = options;
|
||||
Artifacts = artifacts;
|
||||
ArtifactStore = artifactStore;
|
||||
NoteStore = noteStore;
|
||||
}
|
||||
|
||||
public MeetingAssistantOptions Options { get; }
|
||||
@@ -398,12 +205,8 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public MarkdownMeetingArtifactStore ArtifactStore { get; }
|
||||
|
||||
public MarkdownMeetingNoteStore NoteStore { get; }
|
||||
|
||||
public static async Task<ScreenshotFixture> CreateAsync(
|
||||
Action<MeetingAssistantOptions>? configure = null,
|
||||
IReadOnlyList<string>? attendees = null,
|
||||
string userNotes = "")
|
||||
Action<MeetingAssistantOptions>? configure = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var options = new MeetingAssistantOptions
|
||||
@@ -415,9 +218,6 @@ public sealed class MeetingScreenshotServiceTests
|
||||
}
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
var noteStore = new MarkdownMeetingNoteStore(
|
||||
Microsoft.Extensions.Options.Options.Create(options),
|
||||
NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "Notes", "meeting.md"),
|
||||
Path.Combine(root, "Transcripts", "transcript.md"),
|
||||
@@ -425,7 +225,6 @@ public sealed class MeetingScreenshotServiceTests
|
||||
Path.Combine(root, "Summaries", "summary.md"));
|
||||
var artifactStore = new MarkdownMeetingArtifactStore(
|
||||
NullLogger<MarkdownMeetingArtifactStore>.Instance);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
var meeting = MeetingNoteTemplate.Create(
|
||||
"Planning",
|
||||
DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"),
|
||||
@@ -433,37 +232,26 @@ public sealed class MeetingScreenshotServiceTests
|
||||
assistantContextPath: artifacts.AssistantContextPath,
|
||||
summaryPath: artifacts.SummaryPath) with
|
||||
{
|
||||
Path = artifacts.MeetingNotePath,
|
||||
UserNotes = userNotes
|
||||
Path = artifacts.MeetingNotePath
|
||||
};
|
||||
meeting.Frontmatter.Attendees = attendees?.ToList() ?? [];
|
||||
var savedMeeting = await noteStore.SaveAsync(
|
||||
meeting,
|
||||
options,
|
||||
CancellationToken.None);
|
||||
await artifactStore.CreateAssistantContextAsync(
|
||||
artifacts,
|
||||
savedMeeting,
|
||||
meeting,
|
||||
"",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
return new ScreenshotFixture(options, artifacts, artifactStore, noteStore);
|
||||
return new ScreenshotFixture(options, artifacts, artifactStore);
|
||||
}
|
||||
|
||||
public MeetingScreenshotService CreateService(
|
||||
IActiveWindowScreenshotCapture capture,
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
IScreenshotOcrClient ocrClient)
|
||||
{
|
||||
return new MeetingScreenshotService(
|
||||
capture,
|
||||
ArtifactStore,
|
||||
NoteStore,
|
||||
attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance,
|
||||
ocrClient,
|
||||
NullLogger<MeetingScreenshotService>.Instance,
|
||||
meetingWorkflowEngine);
|
||||
NullLogger<MeetingScreenshotService>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,18 +276,15 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public CapturingScreenshotOcrClient(
|
||||
string text = "",
|
||||
ScreenshotCropCoordinates? crop = null,
|
||||
IReadOnlyList<string>? attendees = null)
|
||||
ScreenshotCropCoordinates? crop = null)
|
||||
{
|
||||
result = new ScreenshotOcrResult(text, crop, attendees ?? []);
|
||||
result = new ScreenshotOcrResult(text, crop);
|
||||
}
|
||||
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public string? Prompt { get; private set; }
|
||||
|
||||
public List<string> ScreenshotPaths { get; } = [];
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
@@ -508,7 +293,6 @@ public sealed class MeetingScreenshotServiceTests
|
||||
{
|
||||
CallCount++;
|
||||
Prompt = prompt;
|
||||
ScreenshotPaths.Add(screenshotPath);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -536,79 +320,7 @@ public sealed class MeetingScreenshotServiceTests
|
||||
|
||||
public void Release(string value)
|
||||
{
|
||||
result.TrySetResult(new ScreenshotOcrResult(value, null, []));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly string message;
|
||||
|
||||
public ThrowingScreenshotOcrClient(string message)
|
||||
{
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedScreenshotOcrClient : IScreenshotOcrClient
|
||||
{
|
||||
private readonly Queue<object> results;
|
||||
|
||||
public SequencedScreenshotOcrClient(params object[] results)
|
||||
{
|
||||
this.results = new Queue<object>(results);
|
||||
}
|
||||
|
||||
public List<string> ScreenshotPaths { get; } = [];
|
||||
|
||||
public Task<ScreenshotOcrResult> ExtractAsync(
|
||||
string screenshotPath,
|
||||
string prompt,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ScreenshotPaths.Add(screenshotPath);
|
||||
var result = results.Dequeue();
|
||||
if (result is Exception exception)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult((ScreenshotOcrResult)result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MappingAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> aliases;
|
||||
|
||||
public MappingAttendeeCanonicalizer(IReadOnlyDictionary<string, string> aliases)
|
||||
{
|
||||
this.aliases = aliases;
|
||||
}
|
||||
|
||||
public List<IReadOnlyList<string>> Requests { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(attendees.ToList());
|
||||
var result = attendees
|
||||
.Select(attendee => aliases.TryGetValue(attendee, out var canonical) ? canonical : attendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(result);
|
||||
result.TrySetResult(new ScreenshotOcrResult(value, null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,17 +336,6 @@ public sealed class MeetingScreenshotServiceTests
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static string ExtractScreenshotOcrId(string context)
|
||||
{
|
||||
const string prefix = "<!-- screenshot-ocr:";
|
||||
var start = context.IndexOf(prefix, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0);
|
||||
start += prefix.Length;
|
||||
var end = context.IndexOf(" -->", start, StringComparison.Ordinal);
|
||||
Assert.True(end > start);
|
||||
return context[start..end];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore CA1416
|
||||
|
||||
@@ -24,7 +24,6 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
Assert.Contains("You are the Meeting Assistant summary agent.", instructions);
|
||||
Assert.Contains("include only the most relevant cropped screenshots", instructions);
|
||||
Assert.Contains("Do not include every cropped screenshot", instructions);
|
||||
Assert.Contains("encode spaces in image-link targets as `%20`", instructions);
|
||||
Assert.Contains("Use add_attendee and remove_attendee", instructions);
|
||||
Assert.Contains("partial screenshot", instructions);
|
||||
Assert.Contains("override_speaker", instructions);
|
||||
@@ -40,27 +39,6 @@ public sealed class MeetingSummaryInstructionBuilderTests
|
||||
Assert.Contains("title parameter", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultPromptTreatsAssistantContextAsMeetingMemoryForUncertainty()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = CreateArtifacts(root);
|
||||
await WriteMeetingNoteAsync(artifacts.MeetingNotePath, []);
|
||||
var builder = new MeetingSummaryInstructionBuilder(Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Agent = new AgentOptions { InitialPrompt = " " },
|
||||
Vault = new VaultOptions { ProjectsFolder = Path.Combine(root, "Projects") }
|
||||
}));
|
||||
|
||||
var instructions = await builder.BuildAsync(artifacts, CancellationToken.None);
|
||||
|
||||
Assert.Contains("meeting-specific memory", instructions);
|
||||
Assert.Contains("unexpected problems", instructions);
|
||||
Assert.Contains("missing information", instructions);
|
||||
Assert.Contains("assumptions", instructions);
|
||||
Assert.Contains("write_context", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuilderUsesConfiguredPrompt()
|
||||
{
|
||||
|
||||
@@ -293,44 +293,6 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.Contains("transcript: \"[[../Transcripts/transcript|Transcript]]\"", meetingNote);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsTransformAddedAttendeeBeforeWritingMeetingNote()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
MeetingNotePath: Path.Combine(root, "Meetings", "Notes", "meeting.md"),
|
||||
TranscriptPath: Path.Combine(root, "Meetings", "Transcripts", "transcript.md"),
|
||||
AssistantContextPath: Path.Combine(root, "Meetings", "Assistant Context", "context.md"),
|
||||
SummaryPath: Path.Combine(root, "Meetings", "Summaries", "summary.md"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.MeetingNotePath,
|
||||
"""
|
||||
---
|
||||
title: Meeting
|
||||
attendees: []
|
||||
projects: []
|
||||
transcript: "[[../Transcripts/transcript|Transcript]]"
|
||||
assistant_context: "[[../Assistant Context/context|Assistant Context]]"
|
||||
summary: "[[../Summaries/summary|Summary]]"
|
||||
---
|
||||
|
||||
User note line.
|
||||
""");
|
||||
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
|
||||
var tools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
new MeetingAssistantOptions(),
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
|
||||
Assert.Equal("Added attendee Ada Lovelace.", await tools.AddAttendee("Ada Lovelace (Contoso)"));
|
||||
|
||||
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
|
||||
Assert.Contains("- Ada Lovelace", meetingNote);
|
||||
Assert.DoesNotContain("Ada Lovelace (Contoso)", meetingNote);
|
||||
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToolsOverrideSpeakerInTranscriptAndRecordOverride()
|
||||
{
|
||||
@@ -837,5 +799,4 @@ public sealed class MeetingSummaryToolTests
|
||||
Assert.False(tools.SummaryWasWritten);
|
||||
Assert.False(File.Exists(artifacts.SummaryPath));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -310,10 +310,6 @@ public sealed class MeetingWorkflowEngineTests
|
||||
[InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)]
|
||||
[InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)]
|
||||
[InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)]
|
||||
[InlineData("attendee_added:\n equals: Ada Lovelace", "attendee", null, null, "ada lovelace", true)]
|
||||
[InlineData("attendee_added:\n contains: contoso", "attendee", null, null, "Ada (Contoso)", true)]
|
||||
[InlineData("attendee_added:\n regex: '^Ada .+Contoso\\)$'", "attendee", null, null, "Ada (Contoso)", true)]
|
||||
[InlineData("attendee_added:\n regex: '^Grace'", "attendee", null, null, "Ada (Contoso)", false)]
|
||||
public async Task TriggerMatchingScenarios(
|
||||
string triggerYaml,
|
||||
string eventKind,
|
||||
@@ -322,16 +318,6 @@ public sealed class MeetingWorkflowEngineTests
|
||||
string? speaker,
|
||||
bool shouldRun)
|
||||
{
|
||||
var stepYaml = eventKind == "attendee"
|
||||
? """
|
||||
- uses: set_property
|
||||
property: attendee.name
|
||||
value: Triggered
|
||||
"""
|
||||
: """
|
||||
- uses: add_project
|
||||
value: Triggered
|
||||
""";
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
$$"""
|
||||
rules:
|
||||
@@ -339,21 +325,14 @@ public sealed class MeetingWorkflowEngineTests
|
||||
on:
|
||||
- {{triggerYaml}}
|
||||
steps:
|
||||
{{stepYaml}}
|
||||
- uses: add_project
|
||||
value: Triggered
|
||||
""");
|
||||
|
||||
var workflowEvent = CreateEvent(eventKind, fixture.Artifacts, from, to, speaker);
|
||||
if (eventKind == "attendee")
|
||||
{
|
||||
var transformed = await fixture.Engine.TransformAttendeeAsync(
|
||||
workflowEvent,
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(shouldRun ? "Triggered" : speaker, transformed);
|
||||
return;
|
||||
}
|
||||
|
||||
await fixture.Engine.RunAsync(workflowEvent, fixture.Options, CancellationToken.None);
|
||||
await fixture.Engine.RunAsync(
|
||||
CreateEvent(eventKind, fixture.Artifacts, from, to, speaker),
|
||||
fixture.Options,
|
||||
CancellationToken.None);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered"));
|
||||
@@ -624,34 +603,6 @@ public sealed class MeetingWorkflowEngineTests
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttendeeAddedRuleCanTransformWorkflowAddedAttendee()
|
||||
{
|
||||
var fixture = await WorkflowFixture.CreateAsync(
|
||||
"""
|
||||
rules:
|
||||
- name: add-raw-attendee
|
||||
on:
|
||||
- created: {}
|
||||
steps:
|
||||
- uses: add_attendee
|
||||
value: 'Ada Lovelace (Contoso)'
|
||||
- name: clean-contoso-attendee
|
||||
on:
|
||||
- attendee_added:
|
||||
contains: 'Contoso'
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: attendee.name
|
||||
value: '@Model.Attendee.Name.Replace(" (Contoso)", "")'
|
||||
""");
|
||||
|
||||
await RunCreatedAsync(fixture);
|
||||
|
||||
var meeting = await fixture.ReadMeetingAsync();
|
||||
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress()
|
||||
{
|
||||
@@ -841,7 +792,6 @@ public sealed class MeetingWorkflowEngineTests
|
||||
{
|
||||
"created" => MeetingWorkflowEvent.Created(artifacts),
|
||||
"speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"),
|
||||
"attendee" => MeetingWorkflowEvent.AttendeeAdded(artifacts, speaker ?? "Ada"),
|
||||
"state" => MeetingWorkflowEvent.StateTransition(
|
||||
artifacts,
|
||||
ParseState(from ?? "collecting metadata"),
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
using MeetingAssistant.Recording;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
public sealed class MicrophoneSelectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void BlankConfiguredMicrophoneUsesDefaultDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
|
||||
var selected = selection.Resolve(
|
||||
configuredDeviceId: null,
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
new MicrophoneDevice("other-id", "other microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("default-id", selected?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfiguredMicrophoneUsesMatchingDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
var selected = selection.Resolve(
|
||||
"other-id",
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
new MicrophoneDevice("other-id", "other microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("other-id", selected?.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeMicrophoneSelectionOverridesConfiguredDevice()
|
||||
{
|
||||
var selection = new MicrophoneDeviceSelection();
|
||||
selection.Select("runtime-id");
|
||||
|
||||
var selected = selection.Resolve(
|
||||
"configured-id",
|
||||
new MicrophoneDevice("default-id", "default microphone"),
|
||||
[
|
||||
new MicrophoneDevice("configured-id", "configured microphone"),
|
||||
new MicrophoneDevice("runtime-id", "runtime microphone")
|
||||
]);
|
||||
|
||||
Assert.Equal("runtime-id", selected?.Id);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
[Fact]
|
||||
public void ExtractAgendaStopsBeforeTeamsJoinInformation()
|
||||
{
|
||||
var agenda = OutlookClassicAppointmentMetadata.ExtractAgenda(
|
||||
var agenda = OutlookClassicMeetingMetadataProvider.ExtractAgenda(
|
||||
"""
|
||||
Review current prototype
|
||||
Decide next backend
|
||||
@@ -25,7 +25,7 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
[Fact]
|
||||
public void NormalizeAttendeesDeduplicatesOrganizerAndRecipientEmail()
|
||||
{
|
||||
var attendees = OutlookClassicAppointmentMetadata.NormalizeAttendees([
|
||||
var attendees = OutlookClassicMeetingMetadataProvider.NormalizeAttendees([
|
||||
"Marcus.Altmann@sew-eurodrive.de",
|
||||
"Marcus.Altmann@sew-eurodrive.de <Marcus.Altmann@sew-eurodrive.de>",
|
||||
"Schweigert, Manuel",
|
||||
@@ -37,14 +37,5 @@ public sealed class OutlookClassicMeetingMetadataProviderTests
|
||||
"Schweigert, Manuel"
|
||||
], attendees);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Canceled: Project sync")]
|
||||
[InlineData("Cancelled: Project sync")]
|
||||
[InlineData("Abgesagt: Project sync")]
|
||||
public void SubjectIndicatesCancellationRecognizesOutlookCanceledPrefixes(string subject)
|
||||
{
|
||||
Assert.True(OutlookClassicCom.SubjectIndicatesCancellation(subject));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptAfterDurableAppend()
|
||||
public async Task TranscriptLineWorkflowRuleTransformsLiveTranscriptBeforePersistingMarkdown()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
var rulesPath = Path.Combine(root, "rules.yaml");
|
||||
@@ -784,88 +784,6 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartWithPromptedMetadataBypassesLookupAndRunsWorkflowWithMetadata()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\prompted-metadata-meeting.md");
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var workflowEngine = new MetadataObservingWorkflowEngine(() => noteStore.SavedNote);
|
||||
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,
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
var promptedMetadata = new MeetingMetadata(
|
||||
"Prompted Architecture Sync",
|
||||
["Ada <ada@example.com>"],
|
||||
"Prompted agenda",
|
||||
DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"));
|
||||
|
||||
await coordinator.StartFromPromptAsync(promptedMetadata, CancellationToken.None);
|
||||
|
||||
await WaitUntilAsync(() => workflowEngine.ObservedEvents.Any(entry =>
|
||||
entry.Type == MeetingWorkflowEventType.StateTransition &&
|
||||
entry.Title == "Prompted Architecture Sync"));
|
||||
Assert.Equal(0, metadataProvider.CallCount);
|
||||
Assert.Equal("Prompted Architecture Sync", noteStore.SavedNote?.Frontmatter.Title);
|
||||
Assert.Equal(["Ada <ada@example.com>"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal("Prompted agenda", artifactStore.Agenda);
|
||||
Assert.Equal(DateTimeOffset.Parse("2026-05-19T11:00:00+02:00"), artifactStore.ScheduledEnd);
|
||||
Assert.Collection(
|
||||
workflowEngine.ObservedEvents.Where(entry =>
|
||||
entry.Type is MeetingWorkflowEventType.Created or MeetingWorkflowEventType.StateTransition),
|
||||
created =>
|
||||
{
|
||||
Assert.Equal(MeetingWorkflowEventType.Created, created.Type);
|
||||
Assert.StartsWith("Meeting ", created.Title, StringComparison.Ordinal);
|
||||
},
|
||||
transition =>
|
||||
{
|
||||
Assert.Equal(MeetingWorkflowEventType.StateTransition, transition.Type);
|
||||
Assert.Equal("Prompted Architecture Sync", transition.Title);
|
||||
Assert.Equal(AssistantContextState.CollectingMetadata, transition.FromState);
|
||||
Assert.Equal(AssistantContextState.Transcribing, transition.ToState);
|
||||
});
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartFromPromptWithoutMetadataBypassesLookup()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var metadataProvider = new CountingMeetingMetadataProvider();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: metadataProvider);
|
||||
|
||||
await coordinator.StartFromPromptAsync(null, CancellationToken.None);
|
||||
|
||||
await Task.Delay(100);
|
||||
Assert.Equal(0, metadataProvider.CallCount);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartCanonicalizesOutlookMeetingAttendeesBeforeWritingNote()
|
||||
{
|
||||
@@ -900,39 +818,6 @@ public sealed class RecordingCoordinatorTests
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTransformsMetadataAttendeesBeforeWritingNote()
|
||||
{
|
||||
var audioSource = new ControlledAudioSource();
|
||||
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\metadata-meeting.md");
|
||||
var workflowEngine = new TransformingAttendeeWorkflowEngine("Ada Lovelace (Contoso)", "Ada Lovelace");
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new EchoStreamingTranscriptionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
noteStore,
|
||||
new CapturingMeetingNoteOpener(),
|
||||
new InMemoryMeetingArtifactStore(),
|
||||
new InMemoryRecordedAudioStore(),
|
||||
new CapturingMeetingSummaryPipeline(),
|
||||
Options.Create(new MeetingAssistantOptions()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
meetingMetadataProvider: new FixedMeetingMetadataProvider(new MeetingMetadata(
|
||||
"Architecture Sync",
|
||||
["Ada Lovelace (Contoso)"],
|
||||
"",
|
||||
null)),
|
||||
meetingWorkflowEngine: workflowEngine);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await WaitUntilAsync(() => noteStore.SavedNote?.Frontmatter.Attendees.Count > 0);
|
||||
|
||||
Assert.Equal(["Ada Lovelace"], noteStore.SavedNote?.Frontmatter.Attendees);
|
||||
Assert.Equal(["Ada Lovelace (Contoso)"], workflowEngine.AttendeeRequests);
|
||||
|
||||
await coordinator.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartSkipsOutlookMeetingAttendeesAboveConfiguredImportLimit()
|
||||
{
|
||||
@@ -1468,9 +1353,7 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal([null, "english"], pipelineFactory.ProfileNames);
|
||||
Assert.Contains(transcriptStore.Segments, segment =>
|
||||
segment.Speaker == "System" &&
|
||||
segment.Text.Contains(
|
||||
"Transcription profile changed to english. Speaker recognition and identities reset.",
|
||||
StringComparison.Ordinal));
|
||||
segment.Text.Contains("Transcription profile changed to english", StringComparison.Ordinal));
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
await WaitUntilAsync(() => metadataProvider.CallCount == 1);
|
||||
|
||||
@@ -2238,44 +2121,6 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.NotNull(summaryPipeline.Artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopProcessesMeetingNoteImageEmbedsBeforeSummarizing()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new CapturingMeetingSummaryPipeline();
|
||||
var screenshotService = new BlockingMeetingScreenshotService();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource,
|
||||
new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(),
|
||||
new InMemoryMeetingNoteStore(),
|
||||
new CapturingMeetingNoteOpener(),
|
||||
artifactStore,
|
||||
new InMemoryRecordedAudioStore(),
|
||||
summaryPipeline,
|
||||
Options.Create(CreateOptionsWithoutFinalizer()),
|
||||
NullLogger<MeetingRecordingCoordinator>.Instance,
|
||||
screenshotService: screenshotService,
|
||||
meetingNoteImageOcrService: screenshotService);
|
||||
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
var stop = coordinator.StopAsync(CancellationToken.None);
|
||||
|
||||
await screenshotService.WaitUntilMeetingNoteImageProcessingStartedAsync();
|
||||
await screenshotService.WaitUntilOcrWaitStartedAsync();
|
||||
|
||||
Assert.DoesNotContain(AssistantContextState.Summarizing, artifactStore.States);
|
||||
Assert.Null(summaryPipeline.Artifacts);
|
||||
|
||||
screenshotService.ReleaseOcrWait();
|
||||
await stop;
|
||||
|
||||
Assert.Contains(AssistantContextState.Summarizing, artifactStore.States);
|
||||
Assert.NotNull(summaryPipeline.Artifacts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopMarksAssistantContextErrorWhenSummaryFails()
|
||||
{
|
||||
@@ -3021,46 +2866,6 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MetadataObservingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private readonly Func<MeetingNote?> getMeetingNote;
|
||||
|
||||
public MetadataObservingWorkflowEngine(Func<MeetingNote?> getMeetingNote)
|
||||
{
|
||||
this.getMeetingNote = getMeetingNote;
|
||||
}
|
||||
|
||||
public List<ObservedWorkflowEvent> ObservedEvents { get; } = [];
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var note = getMeetingNote();
|
||||
ObservedEvents.Add(new ObservedWorkflowEvent(
|
||||
workflowEvent.Type,
|
||||
note?.Frontmatter.Title,
|
||||
workflowEvent.FromState,
|
||||
workflowEvent.ToState));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
|
||||
public sealed record ObservedWorkflowEvent(
|
||||
MeetingWorkflowEventType Type,
|
||||
string? Title,
|
||||
AssistantContextState? FromState,
|
||||
AssistantContextState? ToState);
|
||||
}
|
||||
|
||||
private sealed class FailingFirstTranscriptLineWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
public Task RunAsync(
|
||||
@@ -3198,14 +3003,12 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService, IMeetingNoteImageOcrService
|
||||
private sealed class BlockingMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
private readonly TaskCompletionSource waitStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource releaseWait =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource meetingNoteImageProcessingStarted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
@@ -3238,20 +3041,6 @@ public sealed class RecordingCoordinatorTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
meetingNoteImageProcessingStarted.TrySetResult();
|
||||
return Task.FromResult(new MeetingNoteImageOcrQueueResult(0));
|
||||
}
|
||||
|
||||
public Task WaitUntilMeetingNoteImageProcessingStartedAsync()
|
||||
{
|
||||
return meetingNoteImageProcessingStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public Task WaitUntilOcrWaitStartedAsync()
|
||||
{
|
||||
return waitStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
@@ -3894,9 +3683,9 @@ public sealed class RecordingCoordinatorTests
|
||||
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>(Items.ToList());
|
||||
}
|
||||
|
||||
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
|
||||
public Task CompleteAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
Items.RemoveAll(existing => string.Equals(existing.Id, item.Id, StringComparison.Ordinal));
|
||||
Items.RemoveAll(item => string.Equals(item.Id, id, StringComparison.Ordinal));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,5 @@ public sealed class ScreenshotOcrOptionsTests
|
||||
{
|
||||
Assert.Contains("exactly who is in the meeting", ScreenshotOcrOptions.DefaultPrompt);
|
||||
Assert.Contains("partial", ScreenshotOcrOptions.DefaultPrompt);
|
||||
Assert.Contains("\"attendees\"", ScreenshotOcrOptions.DefaultPrompt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class TaskbarIconTests
|
||||
Assert.Equal(RecordingProcessState.Idle, menu.State);
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.EditRules &&
|
||||
item.Text == "Open agent");
|
||||
item.Text == "Settings and logs");
|
||||
Assert.Contains(menu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.StartRecording &&
|
||||
item.ProfileName == "default" &&
|
||||
@@ -31,34 +31,6 @@ public sealed class TaskbarIconTests
|
||||
Assert.DoesNotContain(menu.Items, item => item.Action == MeetingTaskbarAction.AbortRecording);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MenuOffersMicrophoneSubmenuWithEffectiveDeviceChecked()
|
||||
{
|
||||
var menu = MeetingTaskbarMenuBuilder.Build(
|
||||
Status(),
|
||||
[Profile("default")],
|
||||
[
|
||||
new MicrophoneDevice("integrated", "integrated microphone"),
|
||||
new MicrophoneDevice("other", "other microphone")
|
||||
],
|
||||
"integrated");
|
||||
|
||||
var microphoneMenu = Assert.Single(menu.Items, item => item.Text == "Microphone");
|
||||
|
||||
Assert.Equal(MeetingTaskbarAction.OpenSubmenu, microphoneMenu.Action);
|
||||
Assert.NotNull(microphoneMenu.Items);
|
||||
Assert.Contains(microphoneMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SelectMicrophone &&
|
||||
item.MicrophoneDeviceId == "integrated" &&
|
||||
item.Text == "integrated microphone" &&
|
||||
item.IsChecked);
|
||||
Assert.Contains(microphoneMenu.Items, item =>
|
||||
item.Action == MeetingTaskbarAction.SelectMicrophone &&
|
||||
item.MicrophoneDeviceId == "other" &&
|
||||
item.Text == "other microphone" &&
|
||||
!item.IsChecked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordingMenuOffersStopAbortAndOtherProfileSwitches()
|
||||
{
|
||||
@@ -136,7 +108,7 @@ public sealed class TaskbarIconTests
|
||||
{
|
||||
Assert.Equal(
|
||||
requiresConfirmation,
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(state));
|
||||
MeetingTaskbarExitPolicy.RequiresConfirmation(Status(state: state)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using MeetingAssistant.Workflow;
|
||||
|
||||
namespace MeetingAssistant.Tests;
|
||||
|
||||
internal sealed class TransformingAttendeeWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
private readonly string source;
|
||||
private readonly string replacement;
|
||||
|
||||
public TransformingAttendeeWorkflowEngine(string source, string replacement)
|
||||
{
|
||||
this.source = source;
|
||||
this.replacement = replacement;
|
||||
}
|
||||
|
||||
public List<string> AttendeeRequests { get; } = [];
|
||||
|
||||
public Task RunAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> TransformTranscriptLineAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
|
||||
public Task<string> TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
AttendeeRequests.Add(workflowEvent.AttendeeName ?? "");
|
||||
return Task.FromResult(string.Equals(workflowEvent.AttendeeName, source, StringComparison.OrdinalIgnoreCase)
|
||||
? replacement
|
||||
: workflowEvent.AttendeeName ?? "");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ public sealed class WorkflowRulesEditorTests
|
||||
Key = "summary-key",
|
||||
KeyEnv = "SUMMARY_KEY",
|
||||
Model = "summary-model",
|
||||
UseStreaming = true,
|
||||
EnableThinking = true,
|
||||
ReasoningEffort = ReasoningEffortOption.High,
|
||||
ReconnectionAttempts = 7,
|
||||
@@ -34,7 +33,6 @@ public sealed class WorkflowRulesEditorTests
|
||||
var editor = new WorkflowRulesEditorOptions
|
||||
{
|
||||
Model = "editor-model",
|
||||
UseStreaming = false,
|
||||
EnableThinking = false,
|
||||
MaxOutputTokens = 50
|
||||
};
|
||||
@@ -45,7 +43,6 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Equal("summary-key", effective.Key);
|
||||
Assert.Equal("SUMMARY_KEY", effective.KeyEnv);
|
||||
Assert.Equal("editor-model", effective.Model);
|
||||
Assert.False(effective.UseStreaming);
|
||||
Assert.False(effective.EnableThinking);
|
||||
Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort);
|
||||
Assert.Equal(7, effective.ReconnectionAttempts);
|
||||
@@ -445,8 +442,7 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
|
||||
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
|
||||
var note = (await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md")))
|
||||
.Replace("\r\n", "\n", StringComparison.Ordinal);
|
||||
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
|
||||
Assert.Contains("title: Fixed", note);
|
||||
Assert.Contains("projects:\n- Alpha", note);
|
||||
Assert.Contains("Existing body.", note);
|
||||
@@ -640,28 +636,6 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsRefuseSideEffectingAttendeeAddedRules()
|
||||
{
|
||||
var fixture = await CreateRulesEditorFixtureAsync();
|
||||
|
||||
var result = await fixture.Tools.WriteRules("""
|
||||
rules:
|
||||
- name: attendee-side-effect
|
||||
on:
|
||||
- attendee_added:
|
||||
contains: Contoso
|
||||
steps:
|
||||
- uses: add_context
|
||||
value: '@Model.Attendee.Name'
|
||||
""", replace_file: true);
|
||||
|
||||
Assert.StartsWith("Refused: workflow rules are invalid.", result);
|
||||
Assert.Contains("attendee-side-effect", result);
|
||||
Assert.Contains("attendee.name", result);
|
||||
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsAcceptAndParseMaskedProfanityRedactionRule()
|
||||
{
|
||||
@@ -747,45 +721,12 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Contains("read_logs", instructions);
|
||||
Assert.Contains("read_spec_file", instructions);
|
||||
Assert.Contains("list_recent_summaries", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstructionBuilderIncludesProjectSyncGuidanceForCorrectedMeetingNotes()
|
||||
{
|
||||
var builder = new WorkflowRulesEditorInstructionBuilder(
|
||||
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
|
||||
|
||||
var instructions = await builder.BuildAsync(new MeetingAssistantOptions(), CancellationToken.None);
|
||||
|
||||
Assert.Contains("When correcting a meeting note that has project references", instructions);
|
||||
Assert.Contains("read that project's AGENTS.md", instructions);
|
||||
Assert.Contains("new project reference", instructions);
|
||||
Assert.Contains("matching correction", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InstructionBuilderTreatsAssistantContextAsMeetingMemoryDuringRepairs()
|
||||
{
|
||||
var builder = new WorkflowRulesEditorInstructionBuilder(
|
||||
NullLogger<WorkflowRulesEditorInstructionBuilder>.Instance);
|
||||
var options = new MeetingAssistantOptions
|
||||
{
|
||||
WorkflowRulesEditor = new WorkflowRulesEditorOptions
|
||||
{
|
||||
InitialPrompt = "Custom interactive agent instructions."
|
||||
}
|
||||
};
|
||||
|
||||
var instructions = await builder.BuildAsync(options, CancellationToken.None);
|
||||
|
||||
Assert.Contains("Custom interactive agent instructions.", instructions);
|
||||
Assert.Contains("meeting-specific memory", instructions);
|
||||
Assert.Contains("fix or investigate a meeting or summary", instructions);
|
||||
Assert.Contains("read the matching assistant context", instructions);
|
||||
Assert.Contains("problems, missing information, assumptions, prior fixes, and conclusions", instructions);
|
||||
Assert.Contains("append a concise record of your fixes and conclusions", instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
|
||||
{
|
||||
@@ -905,13 +846,15 @@ public sealed class WorkflowRulesEditorTests
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(
|
||||
[new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")],
|
||||
viewModel.Messages.Select(item => item.Message).OfType<WorkflowRulesEditorChatMessage>().ToArray());
|
||||
viewModel.Messages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
AssertCompletedActivity(viewModel, ["Thinking..."], "Updated rules.");
|
||||
Assert.Equal(2, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -927,19 +870,20 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal("", viewModel.Draft);
|
||||
Assert.Equal(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"), viewModel.Messages[0].Message);
|
||||
AssertCompletedActivity(
|
||||
viewModel,
|
||||
["Thinking..."],
|
||||
"Meeting Summary Agent failed: The request timed out.");
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "check logs"),
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
"Settings and logs failed: The request timed out.")
|
||||
],
|
||||
viewModel.Messages.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsReconnectStatusReportedByPipeline()
|
||||
{
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Status("Reconnecting..."));
|
||||
var pipeline = new StatusReportingRulesEditorPipeline("Updated rules.");
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
@@ -956,146 +900,7 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
AssertCompletedActivity(viewModel, ["Thinking...", "Reconnecting..."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsToolCallsAbovePersistentThinkingLine()
|
||||
{
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
Assert.Equal(["Called tool: read_logs"], viewModel.ActivityMessages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Empty(viewModel.ActivityMessages);
|
||||
AssertCompletedActivity(viewModel, ["Thinking...", "Called tool: read_logs"], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelShowsVisibleThinkingOutputInActivityExpander()
|
||||
{
|
||||
var pipeline = new ReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."),
|
||||
WorkflowRulesEditorActivityUpdate.ToolCall("read_logs"),
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Matched the rule to the user request."));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
var sendTask = viewModel.SendAsync();
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal("Thinking...", viewModel.ActivityMessage);
|
||||
Assert.Equal(
|
||||
[
|
||||
"Checked the recent logs.",
|
||||
"Called tool: read_logs",
|
||||
"Matched the rule to the user request."
|
||||
],
|
||||
viewModel.ActivityMessages.ToArray());
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.False(viewModel.IsThinking);
|
||||
Assert.Empty(viewModel.ActivityMessages);
|
||||
AssertCompletedActivity(
|
||||
viewModel,
|
||||
[
|
||||
"Checked the recent logs.",
|
||||
"Called tool: read_logs",
|
||||
"Matched the rule to the user request."
|
||||
],
|
||||
"Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelMarshalsBackgroundActivityUpdatesToCapturedContext()
|
||||
{
|
||||
var previousContext = SynchronizationContext.Current;
|
||||
var context = new RecordingSynchronizationContext();
|
||||
var pipeline = new BackgroundReportingRulesEditorPipeline(
|
||||
"Updated rules.",
|
||||
WorkflowRulesEditorActivityUpdate.Thinking("Checked the recent logs."));
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "check logs"
|
||||
};
|
||||
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
var sendTask = viewModel.SendAsync();
|
||||
SynchronizationContext.SetSynchronizationContext(previousContext);
|
||||
await pipeline.Reported.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.True(viewModel.IsThinking);
|
||||
Assert.Equal(["Checked the recent logs."], viewModel.ActivityMessages.ToArray());
|
||||
Assert.Equal(1, context.SendCount);
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await sendTask.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
AssertCompletedActivity(viewModel, ["Checked the recent logs."], "Updated rules.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewModelKeepsActivityItemsOutOfModelConversation()
|
||||
{
|
||||
var pipeline = new BlockingRulesEditorPipeline("Updated rules.");
|
||||
var viewModel = new WorkflowRulesEditorChatViewModel(pipeline)
|
||||
{
|
||||
Draft = "first"
|
||||
};
|
||||
|
||||
var firstSend = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
pipeline.Release.SetResult();
|
||||
await firstSend.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
pipeline.Reset("Second response.");
|
||||
viewModel.Draft = "second";
|
||||
var secondSend = viewModel.SendAsync();
|
||||
await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "first"),
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, "Updated rules.")
|
||||
],
|
||||
pipeline.LastConversation);
|
||||
|
||||
pipeline.Release.SetResult();
|
||||
await secondSend.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private static void AssertCompletedActivity(
|
||||
WorkflowRulesEditorChatViewModel viewModel,
|
||||
IReadOnlyList<string> expectedActivityLines,
|
||||
string expectedAgentMessage)
|
||||
{
|
||||
Assert.Equal(3, viewModel.Messages.Count);
|
||||
Assert.Equal(WorkflowRulesEditorConversationItemKind.Activity, viewModel.Messages[1].Kind);
|
||||
Assert.StartsWith("Worked for ", viewModel.Messages[1].Content);
|
||||
Assert.Equal(expectedActivityLines, viewModel.Messages[1].ActivityLines);
|
||||
Assert.Equal(WorkflowRulesEditorConversationItemKind.Message, viewModel.Messages[2].Kind);
|
||||
Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[2].Message?.Role);
|
||||
Assert.Equal(expectedAgentMessage, viewModel.Messages[2].Message?.Content);
|
||||
Assert.Equal("Updated rules.", viewModel.Messages[1].Content);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -1380,36 +1185,31 @@ public sealed class WorkflowRulesEditorTests
|
||||
|
||||
private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private string response;
|
||||
private readonly string response;
|
||||
|
||||
public BlockingRulesEditorPipeline(string response)
|
||||
{
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Started { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public IReadOnlyList<WorkflowRulesEditorChatMessage> LastConversation { get; private set; } = [];
|
||||
|
||||
public void Reset(string nextResponse)
|
||||
{
|
||||
response = nextResponse;
|
||||
Started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
Release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
Action<string>? statusChanged = null)
|
||||
{
|
||||
LastConversation = conversation;
|
||||
Started.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1426,23 +1226,19 @@ public sealed class WorkflowRulesEditorTests
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
Action<string>? statusChanged = null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
private sealed class StatusReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
private readonly IReadOnlyList<WorkflowRulesEditorActivityUpdate> updates;
|
||||
|
||||
public ReportingRulesEditorPipeline(
|
||||
string response,
|
||||
params WorkflowRulesEditorActivityUpdate[] updates)
|
||||
public StatusReportingRulesEditorPipeline(string response)
|
||||
{
|
||||
this.response = response;
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -1453,57 +1249,17 @@ public sealed class WorkflowRulesEditorTests
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
Action<string>? statusChanged = null)
|
||||
{
|
||||
foreach (var update in updates)
|
||||
{
|
||||
activityChanged?.Invoke(update);
|
||||
}
|
||||
|
||||
statusChanged?.Invoke("Reconnecting...");
|
||||
Reported.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BackgroundReportingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly string response;
|
||||
private readonly WorkflowRulesEditorActivityUpdate update;
|
||||
|
||||
public BackgroundReportingRulesEditorPipeline(
|
||||
string response,
|
||||
WorkflowRulesEditorActivityUpdate update)
|
||||
{
|
||||
this.response = response;
|
||||
this.update = update;
|
||||
}
|
||||
|
||||
public TaskCompletionSource Reported { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
{
|
||||
await Task.Run(() => activityChanged?.Invoke(update), cancellationToken);
|
||||
Reported.SetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new WorkflowRulesEditorChatResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingSynchronizationContext : SynchronizationContext
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
|
||||
public override void Send(SendOrPostCallback callback, object? state)
|
||||
{
|
||||
SendCount++;
|
||||
callback(state);
|
||||
return new WorkflowRulesEditorChatResult(
|
||||
response,
|
||||
conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response))
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Calendar;
|
||||
@@ -85,8 +84,7 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
foreach (var meeting in cachedMeetings.OrderBy(meeting => meeting.Start))
|
||||
{
|
||||
var promptKey = GetPromptKey(meeting);
|
||||
if (meeting.IsCanceled ||
|
||||
promptedMeetingKeys.Contains(promptKey) ||
|
||||
if (promptedMeetingKeys.Contains(promptKey) ||
|
||||
!IsInPromptWindow(meeting, now, promptOptions))
|
||||
{
|
||||
continue;
|
||||
@@ -99,7 +97,7 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
meeting.Start);
|
||||
await promptService.ShowPromptAsync(
|
||||
new MeetingStartPromptRequest(meeting),
|
||||
(response, token) => HandlePromptResponseAsync(meeting, response, token),
|
||||
HandlePromptResponseAsync,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +133,6 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
}
|
||||
|
||||
private async Task HandlePromptResponseAsync(
|
||||
CalendarMeeting meeting,
|
||||
MeetingStartPromptResponse response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -149,7 +146,7 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
await recordingController.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await recordingController.StartFromPromptAsync(meeting.Metadata, cancellationToken);
|
||||
await recordingController.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void ResetPromptedMeetingsIfDayChanged(DateOnly today)
|
||||
@@ -186,7 +183,6 @@ public sealed class CalendarRecordingPromptScheduler : BackgroundService
|
||||
var syncDelay = nextSyncAt - now;
|
||||
|
||||
var nextMeetingStart = cachedMeetings
|
||||
.Where(meeting => !meeting.IsCanceled)
|
||||
.Where(meeting => !promptedMeetingKeys.Contains(GetPromptKey(meeting)))
|
||||
.Where(meeting => meeting.End > now)
|
||||
.Select(meeting => meeting.Start <= now ? now : meeting.Start)
|
||||
@@ -232,9 +228,7 @@ public sealed record CalendarMeeting(
|
||||
string Id,
|
||||
string Subject,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset End,
|
||||
MeetingMetadata? Metadata = null,
|
||||
bool IsCanceled = false);
|
||||
DateTimeOffset End);
|
||||
|
||||
public interface IMeetingStartPromptService
|
||||
{
|
||||
@@ -258,10 +252,6 @@ public interface IMeetingPromptRecordingController
|
||||
|
||||
Task<RecordingStatus> StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task<RecordingStatus> StartFromPromptAsync(
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<RecordingStatus> StopAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -281,13 +271,6 @@ public sealed class MeetingPromptRecordingController : IMeetingPromptRecordingCo
|
||||
return coordinator.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StartFromPromptAsync(
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return coordinator.StartFromPromptAsync(metadata, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<RecordingStatus> StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return coordinator.StopAsync(cancellationToken);
|
||||
|
||||
@@ -60,21 +60,18 @@ public sealed class OutlookClassicCalendarMeetingProvider : ICalendarMeetingProv
|
||||
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
|
||||
if (end <= dayStart ||
|
||||
start < dayStart ||
|
||||
OutlookClassicCom.IsCanceledAppointment(item) ||
|
||||
!OutlookClassicAppointmentMetadata.IsTeamsAppointment(item))
|
||||
!IsTeamsAppointment(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var subject = OutlookClassicAppointmentMetadata.ReadSubject(item);
|
||||
var localEnd = OutlookClassicCom.ToLocalOffset(end);
|
||||
var rawSubject = Convert.ToString(OutlookClassicCom.GetProperty(item, "Subject"))?.Trim();
|
||||
var subject = string.IsNullOrWhiteSpace(rawSubject) ? "Teams meeting" : rawSubject;
|
||||
meetings.Add(new CalendarMeeting(
|
||||
GetAppointmentId(item, start, end, subject),
|
||||
subject,
|
||||
OutlookClassicCom.ToLocalOffset(start),
|
||||
localEnd,
|
||||
OutlookClassicAppointmentMetadata.CreateMetadata(item, end),
|
||||
IsCanceled: false));
|
||||
OutlookClassicCom.ToLocalOffset(end)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -101,5 +98,13 @@ public sealed class OutlookClassicCalendarMeetingProvider : ICalendarMeetingProv
|
||||
? $"{start:O}|{end:O}|{subject}"
|
||||
: $"{id}|{start:O}";
|
||||
}
|
||||
|
||||
private static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -20,19 +20,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.13.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.11.0" />
|
||||
<PackageReference Include="DiffPlex" Version="1.9.0" />
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="$(MicrosoftSpeechVersion)" />
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech.Extension.MAS" Version="$(MicrosoftSpeechVersion)" ExcludeAssets="build" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<PackageReference Include="NAudio" Version="2.3.0" />
|
||||
<PackageReference Include="NCalcSync" Version="7.0.0" />
|
||||
<PackageReference Include="NCalcSync" Version="6.3.2" />
|
||||
<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" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.9" />
|
||||
<PackageReference Include="Whisper.net" Version="1.9.1" />
|
||||
<PackageReference Include="Whisper.net.Runtime" Version="1.9.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="18.1.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="18.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
|
||||
@@ -59,7 +59,6 @@ public sealed class ScreenshotOcrOptions
|
||||
|
||||
Identify who appears to be talking, who appears to be presenting, and what is being presented when visible.
|
||||
If people or participant tiles are visible, state whether it is clear from the screenshot exactly who is in the meeting or whether the visible people are only a partial participant result.
|
||||
Include any attendee names that are clearly visible or strongly deduced from the screenshot in the meeting-assistant metadata. The attendee list may be partial.
|
||||
If the screenshot contains slides, capture the visible text in clean markdown.
|
||||
If the screenshot contains a diagram, convert it to Mermaid when possible and preserve labels accurately.
|
||||
If it contains another kind of shared screen or scene, describe the scene and the relevant visible information.
|
||||
@@ -68,9 +67,9 @@ public sealed class ScreenshotOcrOptions
|
||||
Do not return crop coordinates for a person, webcam tile, whole app chrome, or content you cannot isolate confidently.
|
||||
End your response with exactly one fenced json block containing meeting-assistant metadata, for example:
|
||||
```json
|
||||
{ "crop": { "x": 0, "y": 0, "width": 1920, "height": 1080 }, "attendees": ["Ada Lovelace"] }
|
||||
{ "crop": { "x": 0, "y": 0, "width": 1920, "height": 1080 } }
|
||||
```
|
||||
Use `{ "crop": null, "attendees": [] }` when no confident presentation/shared-screen crop exists and no attendees are visible or deducible.
|
||||
Use `{ "crop": null }` when no confident presentation/shared-screen crop exists.
|
||||
Do not invent information that is not visible in the screenshot.
|
||||
""";
|
||||
|
||||
@@ -126,8 +125,6 @@ public sealed class RecordingOptions
|
||||
|
||||
public int Channels { get; set; } = 1;
|
||||
|
||||
public string? MicrophoneDeviceId { get; set; }
|
||||
|
||||
public double MicrophoneMixGain { get; set; } = 1;
|
||||
|
||||
public double SystemAudioMixGain { get; set; } = 1;
|
||||
@@ -383,8 +380,6 @@ public sealed class AgentOptions
|
||||
|
||||
public string Model { get; set; } = "chatgpt/gpt-5.5";
|
||||
|
||||
public bool UseStreaming { get; set; } = true;
|
||||
|
||||
public bool EnableThinking { get; set; } = true;
|
||||
|
||||
public ReasoningEffortOption ReasoningEffort { get; set; } = ReasoningEffortOption.Medium;
|
||||
@@ -416,8 +411,6 @@ public sealed class WorkflowRulesEditorOptions
|
||||
|
||||
public string? Model { get; set; }
|
||||
|
||||
public bool? UseStreaming { get; set; }
|
||||
|
||||
public bool? EnableThinking { get; set; }
|
||||
|
||||
public ReasoningEffortOption? ReasoningEffort { get; set; }
|
||||
@@ -446,7 +439,6 @@ public sealed class WorkflowRulesEditorOptions
|
||||
Key = string.IsNullOrWhiteSpace(Key) ? defaults.Key : Key,
|
||||
KeyEnv = string.IsNullOrWhiteSpace(KeyEnv) ? defaults.KeyEnv : KeyEnv!,
|
||||
Model = string.IsNullOrWhiteSpace(Model) ? defaults.Model : Model!,
|
||||
UseStreaming = UseStreaming ?? defaults.UseStreaming,
|
||||
EnableThinking = EnableThinking ?? defaults.EnableThinking,
|
||||
ReasoningEffort = ReasoningEffort ?? defaults.ReasoningEffort,
|
||||
ReconnectionAttempts = ReconnectionAttempts ?? defaults.ReconnectionAttempts,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
@@ -115,24 +113,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
|
||||
private static string EscapeQuoted(string value)
|
||||
{
|
||||
var escaped = new StringBuilder(value.Length + 2);
|
||||
escaped.Append('"');
|
||||
foreach (var character in value)
|
||||
{
|
||||
escaped.Append(character switch
|
||||
{
|
||||
'\\' => "\\\\",
|
||||
'"' => "\\\"",
|
||||
'\r' => "\\r",
|
||||
'\n' => "\\n",
|
||||
'\t' => "\\t",
|
||||
< ' ' => "\\x" + ((int)character).ToString("X2", CultureInfo.InvariantCulture),
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
escaped.Append('"');
|
||||
return escaped.ToString();
|
||||
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
private static string EscapeNullableDateTime(DateTimeOffset? value)
|
||||
@@ -154,7 +135,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (RequiresQuotedScalar(value))
|
||||
if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
return EscapeQuoted(value);
|
||||
}
|
||||
@@ -162,49 +143,6 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool RequiresQuotedScalar(string value)
|
||||
{
|
||||
if (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.Contains(':', StringComparison.Ordinal) ||
|
||||
value.Contains('\'', StringComparison.Ordinal) ||
|
||||
value.Contains(" #", StringComparison.Ordinal) ||
|
||||
value.Any(char.IsControl))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsYamlIndicator(value[0]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (IsYamlCoreSchemaKeyword(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return DateTimeOffset.TryParse(value, out _) ||
|
||||
double.TryParse(value, CultureInfo.InvariantCulture, out _);
|
||||
}
|
||||
|
||||
private static bool IsYamlIndicator(char character)
|
||||
{
|
||||
return character is '-' or '?' or ':' or ',' or '[' or ']' or '{' or '}' or
|
||||
'#' or '&' or '*' or '!' or '|' or '>' or '\'' or '"' or '%' or '@' or '`';
|
||||
}
|
||||
|
||||
private static bool IsYamlCoreSchemaKeyword(string value)
|
||||
{
|
||||
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "null", StringComparison.OrdinalIgnoreCase) ||
|
||||
value == "~";
|
||||
}
|
||||
|
||||
private sealed class MeetingNoteYaml
|
||||
{
|
||||
[YamlMember(Alias = "title")]
|
||||
|
||||
@@ -8,13 +8,4 @@ internal static class MeetingAttendeeNames
|
||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||
}
|
||||
|
||||
public static List<string> NormalizeDistinct(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(attendee => attendee.Trim())
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,24 +14,4 @@ public static class MeetingNoteActionLinks
|
||||
var url = $"{baseUrl}/meetings/summary/retry?summaryPath={Uri.EscapeDataString(summaryFileName)}";
|
||||
return $"[{label}]({url})";
|
||||
}
|
||||
|
||||
public static string CreateScreenshotOcrRetryLink(
|
||||
string publicBaseUrl,
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
string label = "Retry screenshot OCR")
|
||||
{
|
||||
var baseUrl = string.IsNullOrWhiteSpace(publicBaseUrl)
|
||||
? "http://localhost:5090"
|
||||
: publicBaseUrl.TrimEnd('/');
|
||||
var url = $"{baseUrl}/meetings/screenshot-ocr/retry" +
|
||||
$"?meetingNotePath={Uri.EscapeDataString(artifacts.MeetingNotePath)}" +
|
||||
$"&transcriptPath={Uri.EscapeDataString(artifacts.TranscriptPath)}" +
|
||||
$"&assistantContextPath={Uri.EscapeDataString(artifacts.AssistantContextPath)}" +
|
||||
$"&summaryPath={Uri.EscapeDataString(artifacts.SummaryPath)}" +
|
||||
$"&screenshotPath={Uri.EscapeDataString(screenshotPath)}" +
|
||||
$"&screenshotId={Uri.EscapeDataString(screenshotId)}";
|
||||
return $"[{label}]({url})";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
#if WINDOWS
|
||||
using MeetingAssistant.Calendar;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
internal static class OutlookClassicAppointmentMetadata
|
||||
{
|
||||
public static MeetingMetadata CreateMetadata(object appointment, DateTime end)
|
||||
{
|
||||
var title = ReadSubject(appointment);
|
||||
return new MeetingMetadata(
|
||||
title,
|
||||
ReadAttendees(appointment),
|
||||
ExtractAgenda(Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? ""),
|
||||
OutlookClassicCom.ToLocalOffset(end));
|
||||
}
|
||||
|
||||
public static string ReadSubject(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject"))?.Trim();
|
||||
return string.IsNullOrWhiteSpace(subject) ? "Teams meeting" : subject;
|
||||
}
|
||||
|
||||
public static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
|
||||
}
|
||||
|
||||
public static string ExtractAgenda(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
var agendaLines = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
agendaLines.Add(line);
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, agendaLines).Trim();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||
{
|
||||
var attendees = new List<string>();
|
||||
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
|
||||
if (!string.IsNullOrWhiteSpace(organizer))
|
||||
{
|
||||
attendees.Add(organizer.Trim());
|
||||
}
|
||||
|
||||
object? recipients = null;
|
||||
try
|
||||
{
|
||||
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
|
||||
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object? recipient = null;
|
||||
try
|
||||
{
|
||||
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
|
||||
var formatted = FormatRecipient(recipient!);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
{
|
||||
attendees.Add(formatted);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return NormalizeAttendees(attendees);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(NormalizeAttendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
|
||||
.ThenBy(attendee => attendee.Length)
|
||||
.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsTeamsSeparator(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
return trimmed.Length >= 8 &&
|
||||
trimmed.All(character => character is '_' or '-' or '*' or ' ');
|
||||
}
|
||||
|
||||
private static bool IsTeamsJoinLine(string line)
|
||||
{
|
||||
return TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
|
||||
}
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var normalized = attendee.Trim();
|
||||
var mailtoPrefix = "mailto:";
|
||||
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = normalized.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
var name = normalized[..emailStart].Trim();
|
||||
var email = normalized[(emailStart + 1)..emailEnd].Trim();
|
||||
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetAttendeeDeduplicationKey(string attendee)
|
||||
{
|
||||
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = attendee.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
return attendee[(emailStart + 1)..emailEnd].Trim();
|
||||
}
|
||||
|
||||
return attendee.Trim();
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) ||
|
||||
email.Contains('/'))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -108,37 +108,6 @@ internal static class OutlookClassicCom
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsCanceledAppointment(object appointment)
|
||||
{
|
||||
var meetingStatus = TryGetProperty(appointment, "MeetingStatus");
|
||||
if (meetingStatus is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = Convert.ToInt32(meetingStatus);
|
||||
if (status is 5 or 7)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or InvalidCastException or OverflowException)
|
||||
{
|
||||
// Fall back to subject prefixes when Outlook exposes a non-numeric status value.
|
||||
}
|
||||
}
|
||||
|
||||
var subject = Convert.ToString(TryGetProperty(appointment, "Subject")) ?? "";
|
||||
return SubjectIndicatesCancellation(subject);
|
||||
}
|
||||
|
||||
internal static bool SubjectIndicatesCancellation(string subject)
|
||||
{
|
||||
var normalized = subject.TrimStart();
|
||||
return normalized.StartsWith("Canceled:", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Cancelled:", StringComparison.OrdinalIgnoreCase) ||
|
||||
normalized.StartsWith("Abgesagt:", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static DateTimeOffset ToLocalOffset(DateTime value)
|
||||
{
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
|
||||
|
||||
@@ -121,9 +121,7 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
|
||||
var end = Convert.ToDateTime(OutlookClassicCom.GetProperty(item, "End"));
|
||||
if (end < windowStart ||
|
||||
OutlookClassicCom.IsCanceledAppointment(item) ||
|
||||
!OutlookClassicAppointmentMetadata.IsTeamsAppointment(item))
|
||||
if (end < windowStart || !IsTeamsAppointment(item))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -152,7 +150,15 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
return null;
|
||||
}
|
||||
|
||||
return OutlookClassicAppointmentMetadata.CreateMetadata(selected.Appointment, selected.End);
|
||||
var appointment = selected.Appointment;
|
||||
var title = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var attendees = ReadAttendees(appointment);
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return new MeetingMetadata(
|
||||
title.Trim(),
|
||||
attendees,
|
||||
ExtractAgenda(body),
|
||||
OutlookClassicCom.ToLocalOffset(selected.End));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -163,6 +169,158 @@ public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProv
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ExtractAgenda(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
var agendaLines = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
agendaLines.Add(line);
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, agendaLines).Trim();
|
||||
}
|
||||
|
||||
private static bool IsTeamsSeparator(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
return trimmed.Length >= 8 &&
|
||||
trimmed.All(character => character is '_' or '-' or '*' or ' ');
|
||||
}
|
||||
|
||||
private static bool IsTeamsJoinLine(string line)
|
||||
{
|
||||
return TeamsMeetingMarkerDetector.ContainsTeamsMarker(line);
|
||||
}
|
||||
|
||||
private static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Body")) ?? "";
|
||||
return TeamsMeetingMarkerDetector.IsTeamsAppointment(subject, location, body);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||
{
|
||||
var attendees = new List<string>();
|
||||
var organizer = Convert.ToString(OutlookClassicCom.GetProperty(appointment, "Organizer"));
|
||||
if (!string.IsNullOrWhiteSpace(organizer))
|
||||
{
|
||||
attendees.Add(organizer.Trim());
|
||||
}
|
||||
|
||||
object? recipients = null;
|
||||
try
|
||||
{
|
||||
recipients = OutlookClassicCom.GetProperty(appointment, "Recipients");
|
||||
var count = Convert.ToInt32(OutlookClassicCom.GetProperty(recipients!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object? recipient = null;
|
||||
try
|
||||
{
|
||||
recipient = OutlookClassicCom.Invoke(recipients!, "Item", index);
|
||||
var formatted = FormatRecipient(recipient!);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
{
|
||||
attendees.Add(formatted);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
OutlookClassicCom.ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return NormalizeAttendees(attendees);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
||||
{
|
||||
return attendees
|
||||
.Select(NormalizeAttendee)
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.GroupBy(GetAttendeeDeduplicationKey, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group
|
||||
.OrderByDescending(attendee => attendee.Contains('<', StringComparison.Ordinal))
|
||||
.ThenBy(attendee => attendee.Length)
|
||||
.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var normalized = attendee.Trim();
|
||||
var mailtoPrefix = "mailto:";
|
||||
normalized = normalized.Replace(mailtoPrefix, "", StringComparison.OrdinalIgnoreCase);
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var emailStart = normalized.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = normalized.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
var name = normalized[..emailStart].Trim();
|
||||
var email = normalized[(emailStart + 1)..emailEnd].Trim();
|
||||
if (name.Equals(email, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetAttendeeDeduplicationKey(string attendee)
|
||||
{
|
||||
var emailStart = attendee.IndexOf('<', StringComparison.Ordinal);
|
||||
var emailEnd = attendee.LastIndexOf('>');
|
||||
if (emailStart > 0 && emailEnd > emailStart)
|
||||
{
|
||||
return attendee[(emailStart + 1)..emailEnd].Trim();
|
||||
}
|
||||
|
||||
return attendee.Trim();
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
var email = Convert.ToString(OutlookClassicCom.GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) ||
|
||||
email.Contains('/'))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
private sealed record OutlookAppointmentCandidate(
|
||||
object Appointment,
|
||||
DateTime Start,
|
||||
|
||||
+1
-133
@@ -19,8 +19,6 @@ builder.Logging.AddProvider(new MeetingAssistantFileLoggerProvider());
|
||||
builder.Services.Configure<MeetingAssistantOptions>(builder.Configuration.GetSection("MeetingAssistant"));
|
||||
builder.Services.AddSingleton<ILaunchProfileOptionsProvider, ConfigurationLaunchProfileOptionsProvider>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<MicrophoneDeviceSelection>();
|
||||
builder.Services.AddSingleton<IMicrophoneDeviceProvider, WindowsMicrophoneDeviceProvider>();
|
||||
builder.Services.AddSingleton<MicrophoneAudioSource>();
|
||||
builder.Services.AddSingleton<SystemAudioSource>();
|
||||
builder.Services.AddSingleton<IAcousticEchoCancellerFactory, AdaptiveFilterAcousticEchoCancellerFactory>();
|
||||
@@ -72,10 +70,7 @@ builder.Services.AddSingleton<IActiveWindowScreenshotCapture, ActiveWindowScreen
|
||||
builder.Services.AddSingleton<IActiveWindowScreenshotCapture, UnavailableActiveWindowScreenshotCapture>();
|
||||
#endif
|
||||
builder.Services.AddSingleton<IScreenshotOcrClient, LiteLlmScreenshotOcrClient>();
|
||||
builder.Services.AddSingleton<MeetingScreenshotService>();
|
||||
builder.Services.AddSingleton<IMeetingScreenshotService>(sp => sp.GetRequiredService<MeetingScreenshotService>());
|
||||
builder.Services.AddSingleton<IScreenshotOcrRetryRunner>(sp => sp.GetRequiredService<MeetingScreenshotService>());
|
||||
builder.Services.AddSingleton<IMeetingNoteImageOcrService>(sp => sp.GetRequiredService<MeetingScreenshotService>());
|
||||
builder.Services.AddSingleton<IMeetingScreenshotService, MeetingScreenshotService>();
|
||||
builder.Services.AddDbContextFactory<SpeakerIdentityDbContext>((services, dbOptions) =>
|
||||
{
|
||||
var appOptions = services.GetRequiredService<IOptions<MeetingAssistantOptions>>().Value;
|
||||
@@ -415,81 +410,6 @@ app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
|
||||
app.MapPost("/meetings/screenshot-ocr/retry", async (
|
||||
ScreenshotOcrRetryRequest request,
|
||||
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetryScreenshotOcrAsync(
|
||||
null,
|
||||
request,
|
||||
screenshotOcrRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/screenshot-ocr/retry", async (
|
||||
string launchProfile,
|
||||
ScreenshotOcrRetryRequest request,
|
||||
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetryScreenshotOcrAsync(
|
||||
launchProfile,
|
||||
request,
|
||||
screenshotOcrRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
app.MapGet("/meetings/screenshot-ocr/retry", async (
|
||||
string meetingNotePath,
|
||||
string transcriptPath,
|
||||
string assistantContextPath,
|
||||
string summaryPath,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetryScreenshotOcrAsync(
|
||||
null,
|
||||
new ScreenshotOcrRetryRequest(
|
||||
meetingNotePath,
|
||||
transcriptPath,
|
||||
assistantContextPath,
|
||||
summaryPath,
|
||||
screenshotPath,
|
||||
screenshotId),
|
||||
screenshotOcrRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapGet("/profiles/{launchProfile}/meetings/screenshot-ocr/retry", async (
|
||||
string launchProfile,
|
||||
string meetingNotePath,
|
||||
string transcriptPath,
|
||||
string assistantContextPath,
|
||||
string summaryPath,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetryScreenshotOcrAsync(
|
||||
launchProfile,
|
||||
new ScreenshotOcrRetryRequest(
|
||||
meetingNotePath,
|
||||
transcriptPath,
|
||||
assistantContextPath,
|
||||
summaryPath,
|
||||
screenshotPath,
|
||||
screenshotId),
|
||||
screenshotOcrRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
|
||||
static async Task<IResult> RetrySummaryAsync(
|
||||
string? launchProfile,
|
||||
string? summaryPath,
|
||||
@@ -518,50 +438,6 @@ static async Task<IResult> RetrySummaryAsync(
|
||||
});
|
||||
}
|
||||
|
||||
static async Task<IResult> RetryScreenshotOcrAsync(
|
||||
string? launchProfile,
|
||||
ScreenshotOcrRetryRequest request,
|
||||
IScreenshotOcrRetryRunner screenshotOcrRetryRunner,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.MeetingNotePath) ||
|
||||
string.IsNullOrWhiteSpace(request.TranscriptPath) ||
|
||||
string.IsNullOrWhiteSpace(request.AssistantContextPath) ||
|
||||
string.IsNullOrWhiteSpace(request.SummaryPath) ||
|
||||
string.IsNullOrWhiteSpace(request.ScreenshotPath) ||
|
||||
string.IsNullOrWhiteSpace(request.ScreenshotId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Meeting artifact paths, screenshot path, and screenshot id are required." });
|
||||
}
|
||||
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
request.MeetingNotePath,
|
||||
request.TranscriptPath,
|
||||
request.AssistantContextPath,
|
||||
request.SummaryPath);
|
||||
var trigger = await screenshotOcrRetryRunner.TriggerOcrRetryAsync(
|
||||
artifacts,
|
||||
request.ScreenshotPath,
|
||||
request.ScreenshotId,
|
||||
profileOptions,
|
||||
cancellationToken);
|
||||
if (trigger is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "No retryable screenshot OCR block was found for the requested screenshot." });
|
||||
}
|
||||
|
||||
return Results.Accepted(value: new
|
||||
{
|
||||
assistantContextPath = trigger.AssistantContextPath,
|
||||
screenshotPath = trigger.ScreenshotPath,
|
||||
screenshotId = trigger.ScreenshotId,
|
||||
state = "ocr retrying"
|
||||
});
|
||||
}
|
||||
|
||||
app.MapPost("/asr/transcribe-file", async (
|
||||
AsrDiagnosticRequest request,
|
||||
AsrDiagnosticService diagnostics,
|
||||
@@ -668,14 +544,6 @@ public sealed record AsrDiagnosticRequest(string Path, int? NumSpeakers = null);
|
||||
|
||||
public sealed record SummaryRetryRequest(string SummaryPath);
|
||||
|
||||
public sealed record ScreenshotOcrRetryRequest(
|
||||
string MeetingNotePath,
|
||||
string TranscriptPath,
|
||||
string AssistantContextPath,
|
||||
string SummaryPath,
|
||||
string ScreenshotPath,
|
||||
string ScreenshotId);
|
||||
|
||||
public sealed record SpeakerIdentityMergeDiagnosticRequest(double? RecentDays = null);
|
||||
|
||||
public sealed record WorkflowConfigurationReloadResponse(string? RulesPath);
|
||||
|
||||
@@ -66,22 +66,32 @@ public sealed class FileOfflineTranscriptionBacklog : IOfflineTranscriptionBackl
|
||||
return items;
|
||||
}
|
||||
|
||||
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
|
||||
public async Task CompleteAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = GetBacklogFolder(options);
|
||||
var path = GetItemPath(folder, item.Id);
|
||||
var path = GetItemPath(folder, id);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
OfflineTranscriptionBacklogItem? item = null;
|
||||
try
|
||||
{
|
||||
item = JsonSerializer.Deserialize<OfflineTranscriptionBacklogItem>(
|
||||
await File.ReadAllTextAsync(path, cancellationToken),
|
||||
JsonOptions);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not read completed offline transcription backlog item {BacklogItemPath}", path);
|
||||
}
|
||||
|
||||
File.Delete(path);
|
||||
if (File.Exists(item.AudioPath))
|
||||
if (item is not null && File.Exists(item.AudioPath))
|
||||
{
|
||||
DeleteCompletedAudio(item.AudioPath);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GetBacklogFolder(MeetingAssistantOptions options)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneDeviceProvider
|
||||
{
|
||||
IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones();
|
||||
|
||||
MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options);
|
||||
|
||||
IWaveIn CreateCapture(MeetingAssistantOptions options);
|
||||
}
|
||||
@@ -28,7 +28,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly IMeetingScreenshotService screenshotService;
|
||||
private readonly IMeetingNoteImageOcrService meetingNoteImageOcrService;
|
||||
private readonly IMeetingRunArtifactCleaner artifactCleaner;
|
||||
private readonly IMeetingInactivityPromptService inactivityPromptService;
|
||||
private readonly IMeetingInactivityClock inactivityClock;
|
||||
@@ -59,7 +58,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
ILaunchProfileOptionsProvider? launchProfiles = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
|
||||
IMeetingScreenshotService? screenshotService = null,
|
||||
IMeetingNoteImageOcrService? meetingNoteImageOcrService = null,
|
||||
IMeetingRunArtifactCleaner? artifactCleaner = null,
|
||||
IMeetingInactivityPromptService? inactivityPromptService = null,
|
||||
IMeetingInactivityClock? inactivityClock = null,
|
||||
@@ -80,7 +78,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
this.screenshotService = screenshotService ?? NoopMeetingScreenshotService.Instance;
|
||||
this.meetingNoteImageOcrService = meetingNoteImageOcrService ?? NoopMeetingNoteImageOcrService.Instance;
|
||||
this.artifactCleaner = artifactCleaner ?? new MeetingRunArtifactCleaner();
|
||||
this.inactivityPromptService = inactivityPromptService ?? new NoopMeetingInactivityPromptService();
|
||||
this.inactivityClock = inactivityClock ?? new SystemMeetingInactivityClock();
|
||||
@@ -145,28 +142,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await StartAsync(null, null, suppressMetadataLookup: false, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StartFromPromptAsync(
|
||||
MeetingMetadata? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await StartAsync(null, metadata, suppressMetadataLookup: true, cancellationToken);
|
||||
return await StartAsync(null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StartAsync(
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await StartAsync(launchProfileName, null, suppressMetadataLookup: false, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<RecordingStatus> StartAsync(
|
||||
string? launchProfileName,
|
||||
MeetingMetadata? suppliedMetadata,
|
||||
bool suppressMetadataLookup,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
@@ -183,16 +164,14 @@ public sealed class MeetingRecordingCoordinator
|
||||
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
|
||||
var summaryPath = GetSummaryPath(startedAt, runOptions);
|
||||
var meetingNote = MeetingNoteTemplate.Create(
|
||||
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
|
||||
startTime: startedAt,
|
||||
attendees: [],
|
||||
transcriptPath: currentSession.TranscriptPath,
|
||||
assistantContextPath: assistantContextPath,
|
||||
summaryPath: summaryPath);
|
||||
|
||||
currentMeetingNote = await meetingNoteStore.SaveAsync(
|
||||
meetingNote,
|
||||
MeetingNoteTemplate.Create(
|
||||
title: $"Meeting {startedAt:yyyy-MM-dd HH:mm}",
|
||||
startTime: startedAt,
|
||||
attendees: [],
|
||||
transcriptPath: currentSession.TranscriptPath,
|
||||
assistantContextPath: assistantContextPath,
|
||||
summaryPath: summaryPath),
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
currentArtifacts = new MeetingSessionArtifacts(
|
||||
@@ -200,37 +179,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
currentSession.TranscriptPath,
|
||||
assistantContextPath,
|
||||
summaryPath);
|
||||
await meetingArtifactStore.CreateAssistantContextAsync(
|
||||
currentArtifacts,
|
||||
currentMeetingNote,
|
||||
"",
|
||||
null,
|
||||
cancellationToken);
|
||||
await meetingArtifactStore.CreateAssistantContextAsync(currentArtifacts, currentMeetingNote, "", null, cancellationToken);
|
||||
await meetingWorkflowEngine.RunAsync(
|
||||
MeetingWorkflowEvent.Created(currentArtifacts),
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
currentMeetingNote = await meetingNoteStore.ReadAsync(currentMeetingNote.Path, cancellationToken);
|
||||
if (suppliedMetadata is not null)
|
||||
{
|
||||
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(
|
||||
currentArtifacts,
|
||||
currentMeetingNote,
|
||||
@@ -272,20 +226,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
|
||||
currentRun = run;
|
||||
if (suppliedMetadata is null && !suppressMetadataLookup)
|
||||
{
|
||||
_ = Task.Run(
|
||||
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
|
||||
CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Transcribing,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
_ = Task.Run(
|
||||
() => ApplyMeetingMetadataWhenAvailableAsync(startedAt, run),
|
||||
CancellationToken.None);
|
||||
logger.LogInformation("Meeting recording started");
|
||||
|
||||
return CurrentStatus;
|
||||
@@ -502,7 +445,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
$"Transcription profile changed to {targetProfile.Name}. Speaker recognition and identities reset.");
|
||||
$"Transcription profile changed to {targetProfile.Name}.");
|
||||
run.AddLiveSegment(marker);
|
||||
await AppendTranscriptSegmentAsync(run, marker, cancellationToken);
|
||||
|
||||
@@ -699,23 +642,14 @@ public sealed class MeetingRecordingCoordinator
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var formatted = TranscriptLineFormatter.Format(segment);
|
||||
TranscriptLineReference lineReference;
|
||||
if (segment.ReplacesMarkerId is { } replacesMarkerId &&
|
||||
run.TryTakeTranscriptMarker(replacesMarkerId, out var markerReference))
|
||||
{
|
||||
lineReference = await ReplaceTranscriptMarkerAsync(run, markerReference, formatted, cancellationToken);
|
||||
}
|
||||
else if (segment.Kind == TranscriptionSegmentKind.Marker &&
|
||||
segment.MarkerId is { } existingMarkerId &&
|
||||
run.TryTakeTranscriptMarker(existingMarkerId, out var existingMarkerReference))
|
||||
{
|
||||
lineReference = await ReplaceTranscriptMarkerAsync(run, existingMarkerReference, formatted, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
lineReference = await transcriptStore.AppendLineAsync(run.Session, formatted.Line, cancellationToken);
|
||||
}
|
||||
|
||||
var lineReference = segment.ReplacesMarkerId is { } replacesMarkerId &&
|
||||
run.TryTakeTranscriptMarker(replacesMarkerId, out var markerReference)
|
||||
? await ReplaceTranscriptMarkerAsync(run, markerReference, formatted, cancellationToken)
|
||||
: segment.Kind == TranscriptionSegmentKind.Marker &&
|
||||
segment.MarkerId is { } existingMarkerId &&
|
||||
run.TryTakeTranscriptMarker(existingMarkerId, out var existingMarkerReference)
|
||||
? await ReplaceTranscriptMarkerAsync(run, existingMarkerReference, formatted, cancellationToken)
|
||||
: await transcriptStore.AppendLineAsync(run.Session, formatted.Line, cancellationToken);
|
||||
if (segment.Kind == TranscriptionSegmentKind.Marker && segment.MarkerId is { } markerId)
|
||||
{
|
||||
run.SetTranscriptMarker(markerId, lineReference);
|
||||
@@ -1035,7 +969,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
|
||||
await ApplyMeetingMetadataAsync(run.Artifacts, meetingNote, metadata, run.Options, CancellationToken.None);
|
||||
await ApplyMeetingMetadataAsync(meetingNote, metadata, run.Options, CancellationToken.None);
|
||||
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
|
||||
if (currentMeetingNote?.Path == meetingNote.Path)
|
||||
{
|
||||
@@ -1087,7 +1021,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
|
||||
private async Task ApplyMeetingMetadataAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
MeetingMetadata metadata,
|
||||
MeetingAssistantOptions options,
|
||||
@@ -1110,39 +1043,10 @@ public sealed class MeetingRecordingCoordinator
|
||||
return;
|
||||
}
|
||||
|
||||
var canonicalized = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken);
|
||||
meetingNote.Frontmatter.Attendees = await TransformAttendeesAsync(
|
||||
artifacts,
|
||||
canonicalized,
|
||||
options,
|
||||
cancellationToken);
|
||||
meetingNote.Frontmatter.Attendees = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<string>> TransformAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var transformed = new List<string>();
|
||||
foreach (var attendee in attendees)
|
||||
{
|
||||
var value = await TransformAttendeeAsync(artifacts, attendee, options, cancellationToken);
|
||||
var storageValue = string.Equals(value, attendee, StringComparison.Ordinal)
|
||||
? attendee.Trim()
|
||||
: NormalizeAttendeeName(value);
|
||||
var normalized = NormalizeAttendeeName(storageValue);
|
||||
if (!string.IsNullOrWhiteSpace(normalized) &&
|
||||
!transformed.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
transformed.Add(storageValue);
|
||||
}
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
private async Task<List<string>> CanonicalizeAttendeesAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -1457,17 +1361,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
continue;
|
||||
}
|
||||
|
||||
var transformedDisplayName = await TransformAttendeeAsync(
|
||||
artifacts,
|
||||
match.DisplayName.Trim(),
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
var displayName = string.Equals(transformedDisplayName, match.DisplayName.Trim(), StringComparison.Ordinal)
|
||||
? match.DisplayName.Trim()
|
||||
: NormalizeAttendeeName(transformedDisplayName);
|
||||
var acceptedNames = match.AcceptedNames
|
||||
.Append(match.DisplayName)
|
||||
.Append(displayName)
|
||||
.Select(NormalizeAttendeeName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -1486,6 +1381,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
continue;
|
||||
}
|
||||
|
||||
var displayName = match.DisplayName.Trim();
|
||||
meetingNote.Frontmatter.Attendees.Add(displayName);
|
||||
existingNames.Add(NormalizeAttendeeName(displayName));
|
||||
changed = true;
|
||||
@@ -1545,18 +1441,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
||||
}
|
||||
|
||||
private async Task<string> TransformAttendeeAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string attendee,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await meetingWorkflowEngine.TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee),
|
||||
options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static bool RemoveDuplicateAcceptedAliases(
|
||||
List<string> attendees,
|
||||
string displayName,
|
||||
@@ -1621,10 +1505,6 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
try
|
||||
{
|
||||
await meetingNoteImageOcrService.ProcessMeetingNoteImageEmbedsAsync(
|
||||
run.Artifacts,
|
||||
run.Options,
|
||||
cancellationToken);
|
||||
await screenshotService.WaitForPendingOcrAsync(
|
||||
run.Artifacts,
|
||||
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed record MicrophoneDevice(
|
||||
string Id,
|
||||
string Name);
|
||||
|
||||
public sealed record MicrophoneDeviceSnapshot(
|
||||
IReadOnlyList<MicrophoneDevice> Available,
|
||||
MicrophoneDevice? Current);
|
||||
@@ -1,54 +0,0 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneDeviceSelection
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private string? selectedDeviceId;
|
||||
|
||||
public string? SelectedDeviceId
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
return selectedDeviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Select(string deviceId)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
selectedDeviceId = string.IsNullOrWhiteSpace(deviceId)
|
||||
? null
|
||||
: deviceId.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public MicrophoneDevice? Resolve(
|
||||
string? configuredDeviceId,
|
||||
MicrophoneDevice? defaultDevice,
|
||||
IReadOnlyList<MicrophoneDevice> availableDevices)
|
||||
{
|
||||
var selected = SelectedDeviceId;
|
||||
return FindById(availableDevices, selected) ??
|
||||
FindById(availableDevices, configuredDeviceId) ??
|
||||
defaultDevice;
|
||||
}
|
||||
|
||||
private static MicrophoneDevice? FindById(
|
||||
IReadOnlyList<MicrophoneDevice> devices,
|
||||
string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return devices.FirstOrDefault(device => string.Equals(
|
||||
device.Id,
|
||||
id.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,10 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
private readonly IMicrophoneDeviceProvider microphones;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IMicrophoneDeviceProvider microphones,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
public MicrophoneAudioSource(ILogger<MicrophoneAudioSource> logger)
|
||||
{
|
||||
this.microphones = microphones;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -26,7 +22,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureAsync(microphones.CreateCapture(options), options, cancellationToken);
|
||||
return CaptureAsync(new WasapiCapture(), options, cancellationToken);
|
||||
}
|
||||
|
||||
private IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
|
||||
@@ -6,7 +6,7 @@ public interface IOfflineTranscriptionBacklog
|
||||
|
||||
Task<IReadOnlyList<OfflineTranscriptionBacklogItem>> ListAsync(CancellationToken cancellationToken);
|
||||
|
||||
Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken);
|
||||
Task CompleteAsync(string id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record OfflineTranscriptionBacklogItem(
|
||||
@@ -38,7 +38,7 @@ public sealed class NoopOfflineTranscriptionBacklog : IOfflineTranscriptionBackl
|
||||
return Task.FromResult<IReadOnlyList<OfflineTranscriptionBacklogItem>>([]);
|
||||
}
|
||||
|
||||
public Task CompleteAsync(OfflineTranscriptionBacklogItem item, CancellationToken cancellationToken)
|
||||
public Task CompleteAsync(string id, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class OfflineTranscriptionBacklogProcessor
|
||||
{
|
||||
private const int MaxChunkDurationMilliseconds = 1000;
|
||||
|
||||
private readonly IOfflineTranscriptionBacklog backlog;
|
||||
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
|
||||
private readonly ITranscriptStore transcriptStore;
|
||||
@@ -69,7 +72,7 @@ public sealed class OfflineTranscriptionBacklogProcessor
|
||||
try
|
||||
{
|
||||
await ProcessAsync(item, cancellationToken);
|
||||
await backlog.CompleteAsync(item, cancellationToken);
|
||||
await backlog.CompleteAsync(item.Id, cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Completed offline transcription backlog item {BacklogItemId} for recording {RecordingPath}",
|
||||
item.Id,
|
||||
@@ -212,9 +215,29 @@ public sealed class OfflineTranscriptionBacklogProcessor
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in PcmWavAudioChunkReader.ReadChunksAsync(audioPath, cancellationToken: cancellationToken))
|
||||
using var reader = new WaveFileReader(audioPath);
|
||||
var bytesPerSecond = reader.WaveFormat.AverageBytesPerSecond;
|
||||
var chunkSize = Math.Max(
|
||||
reader.WaveFormat.BlockAlign,
|
||||
bytesPerSecond * MaxChunkDurationMilliseconds / 1000);
|
||||
chunkSize -= chunkSize % reader.WaveFormat.BlockAlign;
|
||||
|
||||
var buffer = new byte[chunkSize];
|
||||
while (true)
|
||||
{
|
||||
await pipeline.WriteAsync(chunk, cancellationToken);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await pipeline.WriteAsync(
|
||||
new AudioChunk(
|
||||
buffer[..read],
|
||||
reader.WaveFormat.SampleRate,
|
||||
reader.WaveFormat.Channels),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public static class PcmWavAudioChunkReader
|
||||
{
|
||||
private const int MaxChunkDurationMilliseconds = 1000;
|
||||
|
||||
public static async IAsyncEnumerable<AudioChunk> ReadChunksAsync(
|
||||
string path,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reader = new WaveFileReader(path);
|
||||
try
|
||||
{
|
||||
var format = reader.WaveFormat;
|
||||
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Only 16-bit PCM WAV files are supported. Found {format.Encoding} with {format.BitsPerSample} bits per sample.");
|
||||
}
|
||||
|
||||
var chunkSize = Math.Max(
|
||||
format.BlockAlign,
|
||||
format.AverageBytesPerSecond * MaxChunkDurationMilliseconds / 1000);
|
||||
chunkSize -= chunkSize % format.BlockAlign;
|
||||
|
||||
var buffer = new byte[chunkSize];
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
reader.Dispose();
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// NAudio can throw while disposing reader streams from async iterators after all chunks were read.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using NAudio.CoreAudioApi;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class WindowsMicrophoneDeviceProvider : IMicrophoneDeviceProvider
|
||||
{
|
||||
private readonly MicrophoneDeviceSelection selection;
|
||||
private readonly ILogger<WindowsMicrophoneDeviceProvider> logger;
|
||||
|
||||
public WindowsMicrophoneDeviceProvider(
|
||||
MicrophoneDeviceSelection selection,
|
||||
ILogger<WindowsMicrophoneDeviceProvider> logger)
|
||||
{
|
||||
this.selection = selection;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MicrophoneDevice> GetAvailableMicrophones()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
return enumerator
|
||||
.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active)
|
||||
.Select(ToMicrophoneDevice)
|
||||
.OrderBy(device => device.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not enumerate microphone capture endpoints");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public MicrophoneDeviceSnapshot GetMicrophoneSnapshot(MeetingAssistantOptions options)
|
||||
{
|
||||
var devices = GetAvailableMicrophones();
|
||||
return new MicrophoneDeviceSnapshot(
|
||||
devices,
|
||||
selection.Resolve(options.Recording.MicrophoneDeviceId, GetDefaultMicrophone(), devices));
|
||||
}
|
||||
|
||||
public IWaveIn CreateCapture(MeetingAssistantOptions options)
|
||||
{
|
||||
var current = GetMicrophoneSnapshot(options).Current;
|
||||
if (current is null)
|
||||
{
|
||||
logger.LogInformation("Starting microphone capture from Windows default capture endpoint");
|
||||
return new WasapiCapture();
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Starting microphone capture from {MicrophoneName} ({MicrophoneDeviceId})",
|
||||
current.Name,
|
||||
current.Id);
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
return new WasapiCapture(enumerator.GetDevice(current.Id));
|
||||
}
|
||||
|
||||
private static MicrophoneDevice? GetDefaultMicrophone()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var enumerator = new MMDeviceEnumerator();
|
||||
return ToMicrophoneDevice(enumerator.GetDefaultAudioEndpoint(DataFlow.Capture, Role.Console));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static MicrophoneDevice ToMicrophoneDevice(MMDevice device)
|
||||
{
|
||||
return new MicrophoneDevice(device.ID, device.FriendlyName);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
@@ -105,69 +104,41 @@ public sealed partial class LiteLlmScreenshotOcrClient : IScreenshotOcrClient
|
||||
private static ScreenshotOcrResult ParseOcrResult(string text)
|
||||
{
|
||||
ScreenshotCropCoordinates? crop = null;
|
||||
IReadOnlyList<string> attendees = [];
|
||||
var cleaned = JsonCodeBlockRegex().Replace(text, match =>
|
||||
{
|
||||
if (TryParseMetadata(match.Groups["json"].Value, out var parsedCrop, out var parsedAttendees))
|
||||
if (TryParseCrop(match.Groups["json"].Value, out var parsedCrop))
|
||||
{
|
||||
crop = parsedCrop;
|
||||
attendees = parsedAttendees;
|
||||
return "";
|
||||
}
|
||||
|
||||
return match.Value;
|
||||
}).Trim();
|
||||
return new ScreenshotOcrResult(cleaned, crop, attendees);
|
||||
return new ScreenshotOcrResult(cleaned, crop);
|
||||
}
|
||||
|
||||
private static bool TryParseMetadata(
|
||||
string json,
|
||||
out ScreenshotCropCoordinates? crop,
|
||||
out IReadOnlyList<string> attendees)
|
||||
private static bool TryParseCrop(string json, out ScreenshotCropCoordinates? crop)
|
||||
{
|
||||
crop = null;
|
||||
attendees = [];
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasMetadata = false;
|
||||
if (document.RootElement.TryGetProperty("attendees", out var attendeesElement))
|
||||
{
|
||||
hasMetadata = true;
|
||||
if (attendeesElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
attendees = MeetingAttendeeNames.NormalizeDistinct(attendeesElement
|
||||
.EnumerateArray()
|
||||
.Where(element => element.ValueKind == JsonValueKind.String)
|
||||
.Select(element => element.GetString() ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.RootElement.TryGetProperty("crop", out var cropElement))
|
||||
{
|
||||
return hasMetadata;
|
||||
}
|
||||
|
||||
hasMetadata = true;
|
||||
if (cropElement.ValueKind == JsonValueKind.Null)
|
||||
if (!document.RootElement.TryGetProperty("crop", out var cropElement) ||
|
||||
cropElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cropElement.ValueKind == JsonValueKind.Object &&
|
||||
TryGetInt(cropElement, "x", out var x) &&
|
||||
TryGetInt(cropElement, "y", out var y) &&
|
||||
TryGetInt(cropElement, "width", out var width) &&
|
||||
TryGetInt(cropElement, "height", out var height))
|
||||
if (cropElement.ValueKind != JsonValueKind.Object ||
|
||||
!TryGetInt(cropElement, "x", out var x) ||
|
||||
!TryGetInt(cropElement, "y", out var y) ||
|
||||
!TryGetInt(cropElement, "width", out var width) ||
|
||||
!TryGetInt(cropElement, "height", out var height))
|
||||
{
|
||||
crop = new ScreenshotCropCoordinates(x, y, width, height);
|
||||
return false;
|
||||
}
|
||||
|
||||
crop = new ScreenshotCropCoordinates(x, y, width, height);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
|
||||
@@ -2,10 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Workflow;
|
||||
|
||||
namespace MeetingAssistant.Screenshots;
|
||||
|
||||
@@ -25,24 +22,7 @@ public interface IScreenshotOcrClient
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ScreenshotOcrResult
|
||||
{
|
||||
public ScreenshotOcrResult(
|
||||
string text,
|
||||
ScreenshotCropCoordinates? crop,
|
||||
IReadOnlyList<string>? attendees = null)
|
||||
{
|
||||
Text = text;
|
||||
Crop = crop;
|
||||
Attendees = attendees ?? [];
|
||||
}
|
||||
|
||||
public string Text { get; init; }
|
||||
|
||||
public ScreenshotCropCoordinates? Crop { get; init; }
|
||||
|
||||
public IReadOnlyList<string> Attendees { get; init; }
|
||||
}
|
||||
public sealed record ScreenshotOcrResult(string Text, ScreenshotCropCoordinates? Crop);
|
||||
|
||||
public sealed record ScreenshotCropCoordinates(int X, int Y, int Width, int Height);
|
||||
|
||||
@@ -66,36 +46,11 @@ public interface IMeetingScreenshotService
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IMeetingNoteImageOcrService
|
||||
{
|
||||
Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IScreenshotOcrRetryRunner
|
||||
{
|
||||
Task<MeetingScreenshotOcrRetryTriggerResult?> TriggerOcrRetryAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingScreenshotCaptureResult(
|
||||
string ScreenshotPath,
|
||||
TimeSpan MeetingTimestamp,
|
||||
bool OcrStarted);
|
||||
|
||||
public sealed record MeetingScreenshotOcrRetryTriggerResult(
|
||||
string AssistantContextPath,
|
||||
string ScreenshotPath,
|
||||
string ScreenshotId);
|
||||
|
||||
public sealed record MeetingNoteImageOcrQueueResult(int QueuedCount);
|
||||
|
||||
public sealed class NoopMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
public static NoopMeetingScreenshotService Instance { get; } = new();
|
||||
@@ -125,32 +80,12 @@ public sealed class NoopMeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class NoopMeetingNoteImageOcrService : IMeetingNoteImageOcrService
|
||||
{
|
||||
public static NoopMeetingNoteImageOcrService Instance { get; } = new();
|
||||
|
||||
public Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MeetingNoteImageOcrQueueResult(0));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed partial class MeetingScreenshotService :
|
||||
IMeetingScreenshotService,
|
||||
IScreenshotOcrRetryRunner,
|
||||
IMeetingNoteImageOcrService
|
||||
public sealed class MeetingScreenshotService : IMeetingScreenshotService
|
||||
{
|
||||
private readonly IActiveWindowScreenshotCapture screenshotCapture;
|
||||
private readonly IMeetingArtifactStore artifactStore;
|
||||
private readonly IMeetingNoteStore meetingNoteStore;
|
||||
private readonly ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly IScreenshotOcrClient ocrClient;
|
||||
private readonly ILogger<MeetingScreenshotService> logger;
|
||||
private readonly ConcurrentDictionary<string, List<PendingOcrTask>> pendingOcrByContext = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -159,17 +94,11 @@ public sealed partial class MeetingScreenshotService :
|
||||
public MeetingScreenshotService(
|
||||
IActiveWindowScreenshotCapture screenshotCapture,
|
||||
IMeetingArtifactStore artifactStore,
|
||||
IMeetingNoteStore meetingNoteStore,
|
||||
ISpeakerIdentityAttendeeCanonicalizer attendeeCanonicalizer,
|
||||
IScreenshotOcrClient ocrClient,
|
||||
ILogger<MeetingScreenshotService> logger,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
ILogger<MeetingScreenshotService> logger)
|
||||
{
|
||||
this.screenshotCapture = screenshotCapture;
|
||||
this.artifactStore = artifactStore;
|
||||
this.meetingNoteStore = meetingNoteStore;
|
||||
this.attendeeCanonicalizer = attendeeCanonicalizer;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
this.ocrClient = ocrClient;
|
||||
this.logger = logger;
|
||||
}
|
||||
@@ -206,13 +135,7 @@ public sealed partial class MeetingScreenshotService :
|
||||
TrackOcr(
|
||||
artifacts,
|
||||
ocrCancellation,
|
||||
RunOcrAsync(
|
||||
artifacts,
|
||||
screenshotPath,
|
||||
screenshotId,
|
||||
options,
|
||||
ScreenshotOcrPolicy.CapturedScreenshot,
|
||||
ocrCancellation.Token));
|
||||
RunOcrAsync(artifacts, screenshotPath, screenshotId, options, ocrCancellation.Token));
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -289,100 +212,6 @@ public sealed partial class MeetingScreenshotService :
|
||||
await WaitForPendingOcrAsync(artifacts, timeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingScreenshotOcrRetryTriggerResult?> TriggerOcrRetryAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(screenshotId) ||
|
||||
string.IsNullOrWhiteSpace(screenshotPath) ||
|
||||
!File.Exists(screenshotPath) ||
|
||||
!File.Exists(artifacts.AssistantContextPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var replaced = await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
"_OCR retry pending..._",
|
||||
cancellationToken,
|
||||
preserveMarkers: true,
|
||||
appendWhenMissing: false);
|
||||
if (!replaced)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ocrCancellation = new CancellationTokenSource();
|
||||
TrackOcr(
|
||||
artifacts,
|
||||
ocrCancellation,
|
||||
RunOcrAsync(
|
||||
artifacts,
|
||||
screenshotPath,
|
||||
screenshotId,
|
||||
options,
|
||||
ScreenshotOcrPolicy.CapturedScreenshot,
|
||||
ocrCancellation.Token));
|
||||
return new MeetingScreenshotOcrRetryTriggerResult(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotPath,
|
||||
screenshotId);
|
||||
}
|
||||
|
||||
public async Task<MeetingNoteImageOcrQueueResult> ProcessMeetingNoteImageEmbedsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Screenshots.Ocr.Enabled || !File.Exists(artifacts.MeetingNotePath))
|
||||
{
|
||||
return new MeetingNoteImageOcrQueueResult(0);
|
||||
}
|
||||
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var embeds = FindMeetingNoteImageEmbeds(
|
||||
meetingNote.UserNotes,
|
||||
artifacts.MeetingNotePath,
|
||||
options);
|
||||
var queuedEmbeds = new List<(MeetingNoteImageEmbed Embed, string OcrId)>();
|
||||
foreach (var embed in embeds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ocrId = Guid.NewGuid().ToString("N");
|
||||
await artifactStore.AppendAssistantContextAsync(
|
||||
artifacts,
|
||||
CreateMeetingNoteImageMarkdown(
|
||||
ocrId,
|
||||
embed.OriginalEmbed,
|
||||
embed.ImagePath,
|
||||
artifacts.AssistantContextPath),
|
||||
cancellationToken);
|
||||
queuedEmbeds.Add((embed, ocrId));
|
||||
}
|
||||
|
||||
foreach (var (embed, ocrId) in queuedEmbeds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var ocrCancellation = new CancellationTokenSource();
|
||||
TrackOcr(
|
||||
artifacts,
|
||||
ocrCancellation,
|
||||
RunOcrAsync(
|
||||
artifacts,
|
||||
embed.ImagePath,
|
||||
ocrId,
|
||||
options,
|
||||
ScreenshotOcrPolicy.MeetingNoteImage,
|
||||
ocrCancellation.Token));
|
||||
}
|
||||
|
||||
return new MeetingNoteImageOcrQueueResult(queuedEmbeds.Count);
|
||||
}
|
||||
|
||||
private async Task<string> SaveScreenshotAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
byte[] pngBytes,
|
||||
@@ -405,7 +234,6 @@ public sealed partial class MeetingScreenshotService :
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
MeetingAssistantOptions options,
|
||||
ScreenshotOcrPolicy policy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var timeout = options.Screenshots.Ocr.GetEffectiveTimeout();
|
||||
@@ -420,27 +248,17 @@ public sealed partial class MeetingScreenshotService :
|
||||
: options.Screenshots.Ocr.Prompt,
|
||||
options,
|
||||
timeoutSource.Token);
|
||||
var cropMarkdown = policy.AllowCrop
|
||||
? await TryCreateCropMarkdownAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotPath,
|
||||
result.Crop,
|
||||
timeoutSource.Token)
|
||||
: "";
|
||||
var cropMarkdown = await TryCreateCropMarkdownAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotPath,
|
||||
result.Crop,
|
||||
timeoutSource.Token);
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
cropMarkdown +
|
||||
"### OCR" + Environment.NewLine + Environment.NewLine + result.Text.Trim(),
|
||||
CancellationToken.None);
|
||||
if (policy.AddAttendees)
|
||||
{
|
||||
await AddOcrAttendeesAsync(
|
||||
artifacts,
|
||||
result.Attendees,
|
||||
options,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -451,15 +269,8 @@ public sealed partial class MeetingScreenshotService :
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
CreateOcrFailureMarkdown(
|
||||
artifacts,
|
||||
screenshotPath,
|
||||
screenshotId,
|
||||
options,
|
||||
$"_OCR timed out after {timeout:g}._",
|
||||
policy.IncludeRetryLink),
|
||||
CancellationToken.None,
|
||||
preserveMarkers: true);
|
||||
$"_OCR timed out after {timeout:g}._",
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -467,93 +278,16 @@ public sealed partial class MeetingScreenshotService :
|
||||
await ReplaceOcrPlaceholderAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
screenshotId,
|
||||
CreateOcrFailureMarkdown(
|
||||
artifacts,
|
||||
screenshotPath,
|
||||
screenshotId,
|
||||
options,
|
||||
$"_OCR failed: {exception.Message}_",
|
||||
policy.IncludeRetryLink),
|
||||
CancellationToken.None,
|
||||
preserveMarkers: true);
|
||||
$"_OCR failed: {exception.Message}_",
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddOcrAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var additions = MeetingAttendeeNames.NormalizeDistinct(attendees);
|
||||
if (additions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var transformedAdditions = await TransformAttendeesAsync(
|
||||
artifacts,
|
||||
additions,
|
||||
options,
|
||||
cancellationToken);
|
||||
var canonicalized = await attendeeCanonicalizer.CanonicalizeAsync(
|
||||
meetingNote.Frontmatter.Attendees.Concat(transformedAdditions).ToList(),
|
||||
cancellationToken);
|
||||
if (meetingNote.Frontmatter.Attendees.SequenceEqual(canonicalized, StringComparer.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
meetingNote.Frontmatter.Attendees = canonicalized.ToList();
|
||||
await meetingNoteStore.SaveAsync(meetingNote, cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Added {AttendeeCount} screenshot OCR attendee candidate(s) to meeting note {MeetingNotePath}",
|
||||
additions.Count,
|
||||
artifacts.MeetingNotePath);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not add screenshot OCR attendees to meeting note {MeetingNotePath}",
|
||||
artifacts.MeetingNotePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> TransformAttendeesAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
IReadOnlyList<string> attendees,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var transformed = new List<string>();
|
||||
foreach (var attendee in attendees)
|
||||
{
|
||||
var value = await meetingWorkflowEngine.TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee),
|
||||
options,
|
||||
cancellationToken);
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(value);
|
||||
if (!string.IsNullOrWhiteSpace(normalized) &&
|
||||
!transformed.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
transformed.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
private async Task<bool> ReplaceOcrPlaceholderAsync(
|
||||
private async Task ReplaceOcrPlaceholderAsync(
|
||||
string assistantContextPath,
|
||||
string screenshotId,
|
||||
string replacement,
|
||||
CancellationToken cancellationToken,
|
||||
bool preserveMarkers = false,
|
||||
bool appendWhenMissing = true)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fileLock = contextFileLocks.GetOrAdd(
|
||||
NormalizeContextKey(assistantContextPath),
|
||||
@@ -563,41 +297,28 @@ public sealed partial class MeetingScreenshotService :
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var startMarker = CreateOcrStartMarker(screenshotId);
|
||||
var endMarker = CreateOcrEndMarker(screenshotId);
|
||||
var startMarker = $"<!-- screenshot-ocr:{screenshotId} -->";
|
||||
var endMarker = $"<!-- /screenshot-ocr:{screenshotId} -->";
|
||||
var startIndex = content.IndexOf(startMarker, StringComparison.Ordinal);
|
||||
var endIndex = content.IndexOf(endMarker, StringComparison.Ordinal);
|
||||
if (startIndex < 0 || endIndex < startIndex)
|
||||
{
|
||||
if (appendWhenMissing)
|
||||
{
|
||||
await File.AppendAllTextAsync(
|
||||
assistantContextPath,
|
||||
Environment.NewLine + replacement.TrimEnd() + Environment.NewLine,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return false;
|
||||
await File.AppendAllTextAsync(
|
||||
assistantContextPath,
|
||||
Environment.NewLine + replacement.TrimEnd() + Environment.NewLine,
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
endIndex += endMarker.Length;
|
||||
var updated = preserveMarkers
|
||||
? content[..startIndex] +
|
||||
startMarker +
|
||||
Environment.NewLine +
|
||||
replacement.TrimEnd() +
|
||||
Environment.NewLine +
|
||||
endMarker +
|
||||
content[endIndex..]
|
||||
: content[..startIndex] +
|
||||
replacement.TrimEnd() +
|
||||
content[endIndex..];
|
||||
var updated = content[..startIndex] +
|
||||
replacement.TrimEnd() +
|
||||
content[endIndex..];
|
||||
await File.WriteAllTextAsync(assistantContextPath, updated, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -605,29 +326,6 @@ public sealed partial class MeetingScreenshotService :
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateOcrFailureMarkdown(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string screenshotPath,
|
||||
string screenshotId,
|
||||
MeetingAssistantOptions options,
|
||||
string status,
|
||||
bool includeRetryLink = true)
|
||||
{
|
||||
var markdown = status.TrimEnd();
|
||||
if (includeRetryLink)
|
||||
{
|
||||
markdown += Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
MeetingNoteActionLinks.CreateScreenshotOcrRetryLink(
|
||||
options.Api.PublicBaseUrl,
|
||||
artifacts,
|
||||
screenshotPath,
|
||||
screenshotId);
|
||||
}
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
private async Task<string> TryCreateCropMarkdownAsync(
|
||||
string assistantContextPath,
|
||||
string screenshotPath,
|
||||
@@ -737,189 +435,13 @@ public sealed partial class MeetingScreenshotService :
|
||||
return content +
|
||||
Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
CreateOcrBlock(screenshotId, "_OCR pending..._");
|
||||
}
|
||||
|
||||
private static string CreateMeetingNoteImageMarkdown(
|
||||
string ocrId,
|
||||
string originalEmbed,
|
||||
string imagePath,
|
||||
string assistantContextPath)
|
||||
{
|
||||
var relativePath = ToMarkdownPath(Path.GetRelativePath(
|
||||
Path.GetDirectoryName(assistantContextPath)!,
|
||||
imagePath));
|
||||
return "## Meeting Note Image" +
|
||||
$"<!-- screenshot-ocr:{screenshotId} -->" +
|
||||
Environment.NewLine +
|
||||
"Image from meeting note." +
|
||||
"_OCR pending..._" +
|
||||
Environment.NewLine +
|
||||
$"Original embed: `{originalEmbed.Replace("`", "\\`", StringComparison.Ordinal)}`" +
|
||||
Environment.NewLine +
|
||||
$"" +
|
||||
Environment.NewLine +
|
||||
Environment.NewLine +
|
||||
CreateOcrBlock(ocrId, "_OCR pending..._");
|
||||
$"<!-- /screenshot-ocr:{screenshotId} -->";
|
||||
}
|
||||
|
||||
private static string CreateOcrBlock(string screenshotId, string content)
|
||||
{
|
||||
return CreateOcrStartMarker(screenshotId) +
|
||||
Environment.NewLine +
|
||||
content.TrimEnd() +
|
||||
Environment.NewLine +
|
||||
CreateOcrEndMarker(screenshotId);
|
||||
}
|
||||
|
||||
private static string CreateOcrStartMarker(string screenshotId)
|
||||
{
|
||||
return $"<!-- screenshot-ocr:{screenshotId} -->";
|
||||
}
|
||||
|
||||
private static string CreateOcrEndMarker(string screenshotId)
|
||||
{
|
||||
return $"<!-- /screenshot-ocr:{screenshotId} -->";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MeetingNoteImageEmbed> FindMeetingNoteImageEmbeds(
|
||||
string userNotes,
|
||||
string meetingNotePath,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userNotes))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<MeetingNoteImageEmbed>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match match in ObsidianImageEmbedRegex().Matches(userNotes))
|
||||
{
|
||||
AddResolvedEmbed(result, seen, match.Value, match.Groups["target"].Value, meetingNotePath, options);
|
||||
}
|
||||
|
||||
foreach (Match match in MarkdownImageEmbedRegex().Matches(userNotes))
|
||||
{
|
||||
AddResolvedEmbed(result, seen, match.Value, match.Groups["target"].Value, meetingNotePath, options);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddResolvedEmbed(
|
||||
List<MeetingNoteImageEmbed> embeds,
|
||||
HashSet<string> seen,
|
||||
string originalEmbed,
|
||||
string target,
|
||||
string meetingNotePath,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
var imagePath = ResolveMeetingNoteImagePath(target, meetingNotePath, options);
|
||||
if (imagePath is null || !seen.Add(imagePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
embeds.Add(new MeetingNoteImageEmbed(originalEmbed, imagePath));
|
||||
}
|
||||
|
||||
private static string? ResolveMeetingNoteImagePath(
|
||||
string target,
|
||||
string meetingNotePath,
|
||||
MeetingAssistantOptions options)
|
||||
{
|
||||
var cleaned = CleanEmbedTarget(target);
|
||||
if (string.IsNullOrWhiteSpace(cleaned) ||
|
||||
cleaned.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||
cleaned.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
|
||||
!IsSupportedImagePath(cleaned))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (Path.IsPathRooted(cleaned))
|
||||
{
|
||||
candidates.Add(VaultPath.Resolve(cleaned));
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add(Path.GetFullPath(Path.Combine(Path.GetDirectoryName(meetingNotePath)!, cleaned)));
|
||||
candidates.Add(VaultPath.Resolve(options.Vault, cleaned));
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (Path.GetFileName(cleaned).Equals(cleaned, StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
var vaultRoot = VaultPath.Resolve(options.Vault.BaseFolder);
|
||||
return Directory.EnumerateFiles(vaultRoot, cleaned, SearchOption.AllDirectories)
|
||||
.FirstOrDefault(IsSupportedImagePath);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or DirectoryNotFoundException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string CleanEmbedTarget(string target)
|
||||
{
|
||||
var cleaned = target.Trim();
|
||||
if (cleaned.StartsWith("<", StringComparison.Ordinal) &&
|
||||
cleaned.EndsWith(">", StringComparison.Ordinal) &&
|
||||
cleaned.Length > 1)
|
||||
{
|
||||
cleaned = cleaned[1..^1].Trim();
|
||||
}
|
||||
|
||||
var pipeIndex = cleaned.IndexOf('|', StringComparison.Ordinal);
|
||||
if (pipeIndex >= 0)
|
||||
{
|
||||
cleaned = cleaned[..pipeIndex].Trim();
|
||||
}
|
||||
|
||||
var hashIndex = cleaned.IndexOf('#', StringComparison.Ordinal);
|
||||
if (hashIndex >= 0)
|
||||
{
|
||||
cleaned = cleaned[..hashIndex].Trim();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Uri.UnescapeDataString(cleaned.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return cleaned.Replace('/', Path.DirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsSupportedImagePath(string path)
|
||||
{
|
||||
return Path.GetExtension(path).ToLowerInvariant() switch
|
||||
{
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".bmp" or ".tif" or ".tiff" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"!\[\[(?<target>[^\]\r\n]+)\]\]", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex ObsidianImageEmbedRegex();
|
||||
|
||||
[GeneratedRegex(@"!\[[^\]\r\n]*\]\((?<target>[^)\r\n]+)\)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex MarkdownImageEmbedRegex();
|
||||
|
||||
private static string ResolveAttachmentsFolder(string assistantContextPath, string configuredFolder)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(
|
||||
@@ -957,19 +479,4 @@ public sealed partial class MeetingScreenshotService :
|
||||
}
|
||||
|
||||
private sealed record PendingOcrTask(Task Task, CancellationTokenSource Cancellation);
|
||||
|
||||
private sealed record ScreenshotOcrPolicy(bool AllowCrop, bool AddAttendees, bool IncludeRetryLink)
|
||||
{
|
||||
public static ScreenshotOcrPolicy CapturedScreenshot { get; } = new(
|
||||
AllowCrop: true,
|
||||
AddAttendees: true,
|
||||
IncludeRetryLink: true);
|
||||
|
||||
public static ScreenshotOcrPolicy MeetingNoteImage { get; } = new(
|
||||
AllowCrop: false,
|
||||
AddAttendees: false,
|
||||
IncludeRetryLink: false);
|
||||
}
|
||||
|
||||
private sealed record MeetingNoteImageEmbed(string OriginalEmbed, string ImagePath);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@ public sealed class PassthroughSpeakerIdentityAttendeeCanonicalizer : ISpeakerId
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var distinctAttendees = MeetingAttendeeNames.NormalizeDistinct(attendees);
|
||||
var distinctAttendees = attendees
|
||||
.Select(attendee => attendee.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(distinctAttendees);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
internal static class FunctionInvocationGuard
|
||||
{
|
||||
public static ValueTask<object?> InvokeAsync(
|
||||
FunctionInvocationContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (context.CallContent.Exception is not null)
|
||||
{
|
||||
return ValueTask.FromResult<object?>(new JsonObject
|
||||
{
|
||||
["error"] = new JsonObject
|
||||
{
|
||||
["code"] = "invalid_tool_arguments",
|
||||
["message"] = "Tool arguments must be a valid JSON object."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return context.Function.InvokeAsync(context.Arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,20 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
#pragma warning disable OPENAI001
|
||||
public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
private readonly ResponsesClient responsesClient;
|
||||
private readonly AsyncLocal<string?> requestInitiator = new();
|
||||
private readonly string model;
|
||||
private readonly bool useStreaming;
|
||||
private readonly bool enableThinking;
|
||||
private readonly string reasoningEffort;
|
||||
private readonly int reconnectionAttempts;
|
||||
@@ -30,7 +22,6 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
|
||||
private readonly ILogger? logger;
|
||||
private readonly Action? retrying;
|
||||
private readonly Action<string>? reasoningSummaryChanged;
|
||||
private int responsesRequestCount;
|
||||
|
||||
public LiteLlmResponsesChatClient(
|
||||
@@ -44,9 +35,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true,
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null,
|
||||
bool useStreaming = true)
|
||||
Action? retrying = null)
|
||||
: this(
|
||||
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
|
||||
apiKey,
|
||||
@@ -58,9 +47,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser,
|
||||
retrying,
|
||||
reasoningSummaryChanged,
|
||||
useStreaming)
|
||||
retrying)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -75,15 +62,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions = null,
|
||||
ILogger? logger = null,
|
||||
bool firstRequestIsUser = true,
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null,
|
||||
bool useStreaming = true)
|
||||
Action? retrying = null)
|
||||
{
|
||||
this.httpClient = httpClient;
|
||||
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
responsesClient = CreateResponsesClient(httpClient, apiKey, requestInitiator);
|
||||
this.model = model;
|
||||
this.useStreaming = useStreaming;
|
||||
this.enableThinking = enableThinking;
|
||||
this.reasoningEffort = reasoningEffort;
|
||||
this.reconnectionAttempts = Math.Max(0, reconnectionAttempts);
|
||||
@@ -91,7 +74,6 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
this.compactionOptions = compactionOptions;
|
||||
this.logger = logger;
|
||||
this.retrying = retrying;
|
||||
this.reasoningSummaryChanged = reasoningSummaryChanged;
|
||||
responsesRequestCount = firstRequestIsUser ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -100,50 +82,19 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
httpClient.Dispose();
|
||||
}
|
||||
|
||||
internal static LiteLlmResponsesChatClient Create(
|
||||
AgentOptions options,
|
||||
string apiKey,
|
||||
LiteLlmResponsesCompactionOptions? compactionOptions,
|
||||
ILogger? logger,
|
||||
bool firstRequestIsUser,
|
||||
Action? retrying = null,
|
||||
Action<string>? reasoningSummaryChanged = null)
|
||||
{
|
||||
return new LiteLlmResponsesChatClient(
|
||||
new Uri(options.Endpoint),
|
||||
apiKey,
|
||||
options.Model,
|
||||
options.EnableThinking,
|
||||
options.ReasoningEffort switch
|
||||
{
|
||||
ReasoningEffortOption.None => "none",
|
||||
ReasoningEffortOption.Low => "low",
|
||||
ReasoningEffortOption.High => "high",
|
||||
ReasoningEffortOption.ExtraHigh => "xhigh",
|
||||
_ => "medium"
|
||||
},
|
||||
options.ReconnectionAttempts,
|
||||
options.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser,
|
||||
retrying,
|
||||
reasoningSummaryChanged,
|
||||
options.UseStreaming);
|
||||
}
|
||||
|
||||
public async Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = await CreateCompactedPayloadAsync(messages.ToList(), options, cancellationToken).ConfigureAwait(false);
|
||||
var result = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
ReportVisibleReasoningSummaries(result.ReasoningSummaries);
|
||||
LogResponseDiagnostics(result.Response);
|
||||
ThrowIfResponseHasNoContent(result.Response);
|
||||
LogResponseUsage(result.Response.Usage);
|
||||
return result.Response;
|
||||
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = ParseResponseJson(responseJson);
|
||||
LogResponseDiagnostics(responseJson, response);
|
||||
ThrowIfResponseHasNoContent(responseJson, response);
|
||||
LogResponseUsage(response.Usage);
|
||||
return response;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
@@ -170,36 +121,45 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
: null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractVisibleReasoningSummaries(ChatResponse response)
|
||||
public static ChatResponse ParseResponseJson(string responseJson)
|
||||
{
|
||||
return response.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.OfType<TextReasoningContent>()
|
||||
.Select(content => content.Text)
|
||||
.Where(text => !string.IsNullOrWhiteSpace(text))
|
||||
.ToArray();
|
||||
}
|
||||
using var document = JsonDocument.Parse(responseJson);
|
||||
var root = document.RootElement;
|
||||
var contents = new List<AIContent>();
|
||||
|
||||
private void ReportVisibleReasoningSummaries(IReadOnlyList<string> summaries)
|
||||
{
|
||||
if (reasoningSummaryChanged is null)
|
||||
if (root.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var summary in summaries)
|
||||
{
|
||||
try
|
||||
foreach (var item in output.EnumerateArray())
|
||||
{
|
||||
reasoningSummaryChanged(summary);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger?.LogWarning(
|
||||
exception,
|
||||
"Reasoning summary callback failed; continuing with the LiteLLM response.");
|
||||
var type = GetString(item, "type");
|
||||
if (type == "message")
|
||||
{
|
||||
AddMessageContent(contents, item);
|
||||
}
|
||||
else if (type == "function_call")
|
||||
{
|
||||
contents.Add(new FunctionCallContent(
|
||||
GetRequiredString(item, "call_id"),
|
||||
GetRequiredString(item, "name"),
|
||||
ParseArguments(GetString(item, "arguments"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Role = ChatRole.Assistant,
|
||||
Contents = contents
|
||||
};
|
||||
|
||||
return new ChatResponse(message)
|
||||
{
|
||||
ResponseId = GetString(root, "id"),
|
||||
ModelId = GetString(root, "model"),
|
||||
CreatedAt = GetUnixTimestamp(root, "created_at"),
|
||||
Usage = ParseUsage(root),
|
||||
RawRepresentation = responseJson
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<JsonObject> CreateCompactedPayloadAsync(
|
||||
@@ -385,9 +345,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
return payload;
|
||||
}
|
||||
|
||||
private async Task<ResponsesChatResult> PostWithRetryAsync(
|
||||
JsonObject payload,
|
||||
CancellationToken cancellationToken)
|
||||
private async Task<string> PostWithRetryAsync(JsonObject payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var payloadJson = payload.ToJsonString(JsonOptions);
|
||||
Exception? lastException = null;
|
||||
@@ -398,36 +356,26 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
{
|
||||
var initiator = NextInitiator();
|
||||
LogRequestDiagnostics(payload, initiator, attempt);
|
||||
using var request = CreateJsonRequest(
|
||||
"responses",
|
||||
payloadJson,
|
||||
initiator);
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var createOptions = ModelReaderWriter.Read<CreateResponseOptions>(
|
||||
BinaryData.FromString(payloadJson),
|
||||
ModelReaderWriterOptions.Json)
|
||||
?? throw new InvalidOperationException("Unable to create OpenAI Responses request options.");
|
||||
createOptions.StreamingEnabled = useStreaming;
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
var previousInitiator = requestInitiator.Value;
|
||||
requestInitiator.Value = initiator;
|
||||
try
|
||||
var exception = new InvalidOperationException(
|
||||
$"LiteLLM Responses request failed with {(int)response.StatusCode} {response.ReasonPhrase}: {responseJson}");
|
||||
if (!IsRetryableStatusCode((int)response.StatusCode) || attempt == reconnectionAttempts)
|
||||
{
|
||||
return useStreaming
|
||||
? await GetStreamingResponseAsync(createOptions, cancellationToken).ConfigureAwait(false)
|
||||
: CreateNonStreamingResult((await responsesClient
|
||||
.CreateResponseAsync(createOptions, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
.Value
|
||||
.AsChatResponse(createOptions));
|
||||
throw exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
requestInitiator.Value = previousInitiator;
|
||||
}
|
||||
}
|
||||
catch (ClientResultException exception)
|
||||
when (!IsRetryableStatusCode(exception.Status) || attempt == reconnectionAttempts)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"LiteLLM Responses request failed with {exception.Status}: {exception.Message}",
|
||||
exception);
|
||||
|
||||
lastException = exception;
|
||||
}
|
||||
catch (Exception exception) when (IsRetryableException(exception) && attempt < reconnectionAttempts)
|
||||
{
|
||||
@@ -441,48 +389,6 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
|
||||
}
|
||||
|
||||
private async Task<ResponsesChatResult> GetStreamingResponseAsync(
|
||||
CreateResponseOptions createOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var updates = new List<ChatResponseUpdate>();
|
||||
var reasoningSummaries = new Dictionary<(string ItemId, int SummaryIndex), StringBuilder>();
|
||||
await foreach (var update in responsesClient
|
||||
.CreateResponseStreamingAsync(createOptions, cancellationToken)
|
||||
.AsChatResponseUpdatesAsync(createOptions, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
updates.Add(update);
|
||||
var reasoningUpdate = update.RawRepresentation
|
||||
as StreamingResponseReasoningSummaryTextDeltaUpdate;
|
||||
if (reasoningUpdate is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = (reasoningUpdate.ItemId, reasoningUpdate.SummaryIndex);
|
||||
if (!reasoningSummaries.TryGetValue(key, out var summary))
|
||||
{
|
||||
summary = new StringBuilder();
|
||||
reasoningSummaries[key] = summary;
|
||||
}
|
||||
|
||||
summary.Append(reasoningUpdate.Delta);
|
||||
}
|
||||
|
||||
return new ResponsesChatResult(
|
||||
updates.ToChatResponse(),
|
||||
reasoningSummaries.Values
|
||||
.Select(summary => summary.ToString())
|
||||
.Where(summary => !string.IsNullOrWhiteSpace(summary))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static ResponsesChatResult CreateNonStreamingResult(ChatResponse response)
|
||||
{
|
||||
return new ResponsesChatResult(response, ExtractVisibleReasoningSummaries(response));
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateJsonRequest(
|
||||
string requestUri,
|
||||
string payloadJson,
|
||||
@@ -521,9 +427,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
|
||||
private static bool IsRetryableException(Exception exception)
|
||||
{
|
||||
return exception is ClientResultException clientException
|
||||
&& IsRetryableStatusCode(clientException.Status)
|
||||
|| exception is HttpRequestException or TaskCanceledException;
|
||||
return exception is HttpRequestException or TaskCanceledException;
|
||||
}
|
||||
|
||||
private void LogContextWindow(int estimatedTokens, bool compacted, string source)
|
||||
@@ -595,18 +499,18 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
instructionsPreview);
|
||||
}
|
||||
|
||||
private void LogResponseDiagnostics(ChatResponse response)
|
||||
private void LogResponseDiagnostics(string responseJson, ChatResponse response)
|
||||
{
|
||||
if (logger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var outputTypes = GetOutputItemTypes(responseJson);
|
||||
var parsedTypes = response.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.Select(content => content.GetType().Name)
|
||||
.ToArray();
|
||||
var outputTypes = GetOutputItemTypes(response);
|
||||
var functionCalls = response.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.OfType<FunctionCallContent>()
|
||||
@@ -624,29 +528,40 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
if (parsedTypes.Length == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LiteLLM Responses response contained no parseable message text or function calls.");
|
||||
"LiteLLM Responses response contained no parseable message text or function calls. Raw response preview: {ResponsePreview}",
|
||||
Truncate(responseJson, maxLength: 6000));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ThrowIfResponseHasNoContent(ChatResponse response)
|
||||
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
|
||||
{
|
||||
if (response.Messages.SelectMany(message => message.Contents).Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var outputTypes = GetOutputItemTypes(response);
|
||||
var outputTypes = GetOutputItemTypes(responseJson);
|
||||
throw new InvalidOperationException(
|
||||
"LiteLLM Responses returned no parseable message text or function calls. " +
|
||||
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
|
||||
}
|
||||
|
||||
private static string[] GetOutputItemTypes(ChatResponse response)
|
||||
private static string[] GetOutputItemTypes(string responseJson)
|
||||
{
|
||||
return response.Messages
|
||||
.SelectMany(message => message.Contents)
|
||||
.Select(content => content.RawRepresentation?.GetType().Name ?? content.GetType().Name)
|
||||
.ToArray();
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(responseJson);
|
||||
return document.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array
|
||||
? output
|
||||
.EnumerateArray()
|
||||
.Select(item => GetString(item, "type") ?? item.ValueKind.ToString())
|
||||
.ToArray()
|
||||
: [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ["<invalid-json>"];
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
@@ -672,19 +587,10 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var isAssistant = role == ChatRole.Assistant.Value;
|
||||
input.Add(new JsonObject
|
||||
{
|
||||
["type"] = "message",
|
||||
["role"] = isAssistant ? "assistant" : "user",
|
||||
["content"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["type"] = isAssistant ? "output_text" : "input_text",
|
||||
["text"] = text
|
||||
}
|
||||
}
|
||||
["role"] = role == ChatRole.Assistant.Value ? "assistant" : "user",
|
||||
["content"] = text
|
||||
});
|
||||
}
|
||||
|
||||
@@ -697,7 +603,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
["type"] = "function_call",
|
||||
["call_id"] = call.CallId,
|
||||
["name"] = call.Name,
|
||||
["arguments"] = SerializeFunctionArguments(call)
|
||||
["arguments"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)
|
||||
});
|
||||
}
|
||||
else if (content is FunctionResultContent result)
|
||||
@@ -734,15 +640,50 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string SerializeFunctionArguments(FunctionCallContent call)
|
||||
private static void AddMessageContent(List<AIContent> contents, JsonElement item)
|
||||
{
|
||||
if (call.RawRepresentation is FunctionCallResponseItem responseItem
|
||||
&& responseItem.FunctionArguments is not null)
|
||||
if (!item.TryGetProperty("content", out var messageContent) || messageContent.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return responseItem.FunctionArguments.ToString();
|
||||
return;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(call.Arguments, JsonOptions);
|
||||
foreach (var content in messageContent.EnumerateArray())
|
||||
{
|
||||
if (GetString(content, "type") == "output_text")
|
||||
{
|
||||
var text = GetString(content, "text");
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
contents.Add(new TextContent(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ParseArguments(string? arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(arguments);
|
||||
return document.RootElement.EnumerateObject()
|
||||
.ToDictionary(property => property.Name, property => ConvertJsonValue(property.Value));
|
||||
}
|
||||
|
||||
private static object? ConvertJsonValue(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number when element.TryGetInt64(out var integer) => integer,
|
||||
JsonValueKind.Number when element.TryGetDouble(out var number) => number,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
_ => JsonSerializer.Deserialize<object>(element.GetRawText(), JsonOptions)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResultToString(object? result)
|
||||
@@ -755,12 +696,57 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
};
|
||||
}
|
||||
|
||||
private static UsageDetails? ParseUsage(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("usage", out var usage) || usage.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var inputTokens = GetInt(usage, "input_tokens") ?? GetInt(usage, "prompt_tokens");
|
||||
var outputTokens = GetInt(usage, "output_tokens") ?? GetInt(usage, "completion_tokens");
|
||||
var totalTokens = GetInt(usage, "total_tokens");
|
||||
return new UsageDetails
|
||||
{
|
||||
InputTokenCount = inputTokens,
|
||||
OutputTokenCount = outputTokens,
|
||||
TotalTokenCount = totalTokens
|
||||
};
|
||||
}
|
||||
|
||||
private static int EstimateTokens(JsonObject payload)
|
||||
{
|
||||
var json = payload.ToJsonString(JsonOptions);
|
||||
return Math.Max(1, (int)Math.Ceiling(json.Length / 4.0));
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string GetRequiredString(JsonElement element, string propertyName)
|
||||
{
|
||||
return GetString(element, propertyName)
|
||||
?? throw new InvalidOperationException($"LiteLLM response item did not include '{propertyName}'.");
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? GetUnixTimestamp(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property) && property.TryGetInt64(out var value)
|
||||
? DateTimeOffset.FromUnixTimeSeconds(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Uri NormalizeEndpoint(Uri endpoint)
|
||||
{
|
||||
var value = endpoint.ToString().TrimEnd('/');
|
||||
@@ -776,60 +762,5 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
|
||||
{
|
||||
return value.TrimStart('/');
|
||||
}
|
||||
|
||||
private static ResponsesClient CreateResponsesClient(
|
||||
HttpClient httpClient,
|
||||
string apiKey,
|
||||
AsyncLocal<string?> requestInitiator)
|
||||
{
|
||||
var options = new OpenAIClientOptions
|
||||
{
|
||||
Endpoint = httpClient.BaseAddress
|
||||
?? throw new InvalidOperationException("LiteLLM HTTP client requires a base address."),
|
||||
Transport = new HttpClientPipelineTransport(httpClient),
|
||||
RetryPolicy = new ClientRetryPolicy(0)
|
||||
};
|
||||
options.AddPolicy(
|
||||
new InitiatorHeaderPolicy(requestInitiator),
|
||||
PipelinePosition.PerCall);
|
||||
|
||||
return new ResponsesClient(
|
||||
new ApiKeyCredential(apiKey),
|
||||
options);
|
||||
}
|
||||
|
||||
private sealed record ResponsesChatResult(
|
||||
ChatResponse Response,
|
||||
IReadOnlyList<string> ReasoningSummaries);
|
||||
|
||||
private sealed class InitiatorHeaderPolicy(AsyncLocal<string?> requestInitiator) : PipelinePolicy
|
||||
{
|
||||
public override void Process(
|
||||
PipelineMessage message,
|
||||
IReadOnlyList<PipelinePolicy> pipeline,
|
||||
int currentIndex)
|
||||
{
|
||||
SetHeader(message);
|
||||
ProcessNext(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
public override ValueTask ProcessAsync(
|
||||
PipelineMessage message,
|
||||
IReadOnlyList<PipelinePolicy> pipeline,
|
||||
int currentIndex)
|
||||
{
|
||||
SetHeader(message);
|
||||
return ProcessNextAsync(message, pipeline, currentIndex);
|
||||
}
|
||||
|
||||
private void SetHeader(PipelineMessage message)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requestInitiator.Value))
|
||||
{
|
||||
message.Request.Headers.Set("X-Initiator", requestInitiator.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#pragma warning restore OPENAI001
|
||||
#pragma warning restore MAAI001
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
write_summary is only for the current meeting's summary file. Past project meeting summaries are read-only historical context; use list_past_project_meetings and read_past_project_meeting_summary to inspect them, and never try to mutate them.
|
||||
If the meeting note has no title, or still has a generated default title like `Meeting yyyy-MM-dd HH:mm`, provide a concise title parameter to write_summary when the purpose of the meeting is clear from transcript, user notes, or assistant context. If the purpose is not clear, omit the title parameter.
|
||||
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
|
||||
Treat the assistant context as persistent meeting-specific memory and use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Whenever you encounter unexpected problems, discover missing information, or make assumptions while summarizing, use write_context to append a concise note so later agents working on this meeting can understand them. Also record useful internal notes, requests for future tools, suggested improvements, and relevant context discovered from other sources. write_context appends by default; use replace_file=true only when you are intentionally replacing the whole assistant context body. Keep user-facing summary content in the summary note.
|
||||
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. write_context appends by default; use replace_file=true only when you are intentionally replacing the whole assistant context body. Keep user-facing summary content in the summary note.
|
||||
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
|
||||
Use add_attendee and remove_attendee to sharpen the meeting attendees list from clear transcript evidence and screenshot OCR participant evidence. Treat OCR that says visible people are a partial screenshot result as incomplete evidence; do not remove attendees solely because they are absent from a partial screenshot.
|
||||
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
|
||||
@@ -22,7 +22,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search, list_past_project_meetings, read_past_project_meeting_summary, and read_projectfile before changing existing project files. search includes both project files and past meeting summaries for the requested current-meeting project scope.
|
||||
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. When embedding them, encode spaces in image-link targets as `%20`; never leave literal spaces in the link target. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
If the assistant context contains cropped screenshot markdown links, include only the most relevant cropped screenshots in the summary by markdown-linking them near the related summary text. Do not include every cropped screenshot by default, and do not link uncropped screenshots unless no cropped version exists and the image is important.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
""";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
@@ -14,7 +13,6 @@ public sealed class MeetingSummaryTools
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly IDictationWordStore? dictationWordStore;
|
||||
private readonly SummaryAgentWriteAudit? writeAudit;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly BoundMeetingProjectResolver projectResolver;
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||
@@ -26,14 +24,12 @@ public sealed class MeetingSummaryTools
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
IDictationWordStore? dictationWordStore = null,
|
||||
SummaryAgentWriteAudit? writeAudit = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
SummaryAgentWriteAudit? writeAudit = null)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
this.options = options;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.writeAudit = writeAudit;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
projectResolver = new BoundMeetingProjectResolver(options);
|
||||
}
|
||||
|
||||
@@ -102,11 +98,7 @@ public sealed class MeetingSummaryTools
|
||||
return "Refused: attendee must not be empty.";
|
||||
}
|
||||
|
||||
var displayName = await meetingWorkflowEngine.TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent.AttendeeAdded(artifacts, attendee.Trim()),
|
||||
options,
|
||||
CancellationToken.None);
|
||||
displayName = displayName.Trim();
|
||||
var displayName = attendee.Trim();
|
||||
var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -18,7 +17,6 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
private readonly IMeetingSummaryFailureWriter failureWriter;
|
||||
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
@@ -27,8 +25,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
IServiceProvider services,
|
||||
IMeetingSummaryFailureWriter failureWriter,
|
||||
IMeetingSummaryInstructionBuilder instructionBuilder,
|
||||
IDictationWordStore dictationWordStore,
|
||||
IMeetingWorkflowEngine meetingWorkflowEngine)
|
||||
IDictationWordStore dictationWordStore)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
@@ -37,7 +34,6 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
this.failureWriter = failureWriter;
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
@@ -55,32 +51,35 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
var agentOptions = options.Agent;
|
||||
var key = ResolveApiKey(agentOptions);
|
||||
var writeAudit = new SummaryAgentWriteAudit(artifacts);
|
||||
var meetingTools = new MeetingSummaryTools(
|
||||
artifacts,
|
||||
options,
|
||||
dictationWordStore,
|
||||
writeAudit,
|
||||
meetingWorkflowEngine);
|
||||
var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit);
|
||||
var tools = CreateTools(meetingTools);
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
|
||||
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||
agentOptions,
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions: null,
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||
using var chatClient = LiteLlmResponsesChatClient.Create(
|
||||
agentOptions,
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var agent = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(
|
||||
loggerFactory,
|
||||
client => client.FunctionInvoker = FunctionInvocationGuard.InvokeAsync)
|
||||
.UseFunctionInvocation()
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
instructions,
|
||||
@@ -304,6 +303,18 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
return new PipelineCompactionStrategy(strategies);
|
||||
}
|
||||
|
||||
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => "none",
|
||||
ReasoningEffortOption.Low => "low",
|
||||
ReasoningEffortOption.High => "high",
|
||||
ReasoningEffortOption.ExtraHigh => "xhigh",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
|
||||
@@ -6,12 +6,10 @@ namespace MeetingAssistant.Taskbar;
|
||||
public enum MeetingTaskbarAction
|
||||
{
|
||||
EditRules,
|
||||
OpenSubmenu,
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
AbortRecording,
|
||||
SwitchProfile,
|
||||
SelectMicrophone,
|
||||
Exit
|
||||
}
|
||||
|
||||
@@ -23,29 +21,19 @@ public sealed record MeetingTaskbarMenu(
|
||||
public sealed record MeetingTaskbarMenuItem(
|
||||
string Text,
|
||||
MeetingTaskbarAction Action,
|
||||
string? ProfileName = null,
|
||||
string? MicrophoneDeviceId = null,
|
||||
bool IsChecked = false,
|
||||
IReadOnlyList<MeetingTaskbarMenuItem>? Items = null);
|
||||
string? ProfileName = null);
|
||||
|
||||
public static class MeetingTaskbarMenuBuilder
|
||||
{
|
||||
public static MeetingTaskbarMenu Build(
|
||||
RecordingStatus status,
|
||||
IReadOnlyList<LaunchProfile> launchProfiles,
|
||||
IReadOnlyList<MicrophoneDevice>? microphones = null,
|
||||
string? currentMicrophoneDeviceId = null)
|
||||
IReadOnlyList<LaunchProfile> launchProfiles)
|
||||
{
|
||||
var items = new List<MeetingTaskbarMenuItem>
|
||||
{
|
||||
new("Open agent", MeetingTaskbarAction.EditRules)
|
||||
new("Settings and logs", MeetingTaskbarAction.EditRules)
|
||||
};
|
||||
|
||||
if (microphones is { Count: > 0 })
|
||||
{
|
||||
items.Add(BuildMicrophoneMenu(microphones, currentMicrophoneDeviceId));
|
||||
}
|
||||
|
||||
if (status.IsRecording)
|
||||
{
|
||||
items.Add(new MeetingTaskbarMenuItem(
|
||||
@@ -84,24 +72,6 @@ public static class MeetingTaskbarMenuBuilder
|
||||
items);
|
||||
}
|
||||
|
||||
private static MeetingTaskbarMenuItem BuildMicrophoneMenu(
|
||||
IReadOnlyList<MicrophoneDevice> microphones,
|
||||
string? currentMicrophoneDeviceId)
|
||||
{
|
||||
var microphoneItems = microphones
|
||||
.Select(microphone => new MeetingTaskbarMenuItem(
|
||||
microphone.Name,
|
||||
MeetingTaskbarAction.SelectMicrophone,
|
||||
MicrophoneDeviceId: microphone.Id,
|
||||
IsChecked: string.Equals(microphone.Id, currentMicrophoneDeviceId, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
|
||||
return new MeetingTaskbarMenuItem(
|
||||
"Microphone",
|
||||
MeetingTaskbarAction.OpenSubmenu,
|
||||
Items: microphoneItems);
|
||||
}
|
||||
|
||||
private static string BuildTooltip(RecordingStatus status)
|
||||
{
|
||||
return status.State switch
|
||||
@@ -134,8 +104,8 @@ public static class MeetingTaskbarMenuBuilder
|
||||
|
||||
public static class MeetingTaskbarExitPolicy
|
||||
{
|
||||
public static bool RequiresConfirmation(RecordingProcessState state)
|
||||
public static bool RequiresConfirmation(RecordingStatus status)
|
||||
{
|
||||
return state != RecordingProcessState.Idle;
|
||||
return status.State != RecordingProcessState.Idle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
|
||||
private readonly MeetingRecordingCoordinator coordinator;
|
||||
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
||||
private readonly IMicrophoneDeviceProvider microphones;
|
||||
private readonly MicrophoneDeviceSelection microphoneSelection;
|
||||
private readonly IWorkflowRulesEditorWindowService rulesEditorWindow;
|
||||
private readonly IHostApplicationLifetime applicationLifetime;
|
||||
private readonly ILogger<UnoTaskbarIconService> logger;
|
||||
@@ -31,16 +29,12 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
public UnoTaskbarIconService(
|
||||
MeetingRecordingCoordinator coordinator,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
IMicrophoneDeviceProvider microphones,
|
||||
MicrophoneDeviceSelection microphoneSelection,
|
||||
IWorkflowRulesEditorWindowService rulesEditorWindow,
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILogger<UnoTaskbarIconService> logger)
|
||||
{
|
||||
this.coordinator = coordinator;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.microphones = microphones;
|
||||
this.microphoneSelection = microphoneSelection;
|
||||
this.rulesEditorWindow = rulesEditorWindow;
|
||||
this.applicationLifetime = applicationLifetime;
|
||||
this.logger = logger;
|
||||
@@ -173,8 +167,6 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
private MeetingTaskbarMenu BuildMenu()
|
||||
{
|
||||
var profiles = launchProfiles.GetProfiles();
|
||||
var activeOptions = GetActiveProfileOptions(profiles);
|
||||
var microphoneSnapshot = microphones.GetMicrophoneSnapshot(activeOptions);
|
||||
var profilesSignature = string.Join(
|
||||
", ",
|
||||
profiles.Select(profile => $"{profile.Name}:{profile.Options.Hotkey.Toggle}"));
|
||||
@@ -186,9 +178,7 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
|
||||
return MeetingTaskbarMenuBuilder.Build(
|
||||
coordinator.CurrentStatus,
|
||||
profiles,
|
||||
microphoneSnapshot.Available,
|
||||
microphoneSnapshot.Current?.Id);
|
||||
profiles);
|
||||
}
|
||||
|
||||
private PopupMenu BuildContextMenu(MeetingTaskbarMenu menu)
|
||||
@@ -204,33 +194,14 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
}
|
||||
|
||||
var menuItem = menu.Items[index];
|
||||
popupMenu.Items.Add(BuildPopupItem(menuItem));
|
||||
popupMenu.Items.Add(new PopupMenuItem(
|
||||
menuItem.Text,
|
||||
(_, _) => _ = ExecuteMenuItemAsync(menuItem)));
|
||||
}
|
||||
|
||||
return popupMenu;
|
||||
}
|
||||
|
||||
private PopupItem BuildPopupItem(MeetingTaskbarMenuItem menuItem)
|
||||
{
|
||||
if (menuItem.Items is { Count: > 0 })
|
||||
{
|
||||
var submenu = new PopupSubMenu(menuItem.Text);
|
||||
foreach (var child in menuItem.Items)
|
||||
{
|
||||
submenu.Items.Add(BuildPopupItem(child));
|
||||
}
|
||||
|
||||
return submenu;
|
||||
}
|
||||
|
||||
return new PopupMenuItem(
|
||||
menuItem.Text,
|
||||
(_, _) => _ = ExecuteMenuItemAsync(menuItem))
|
||||
{
|
||||
Checked = menuItem.IsChecked
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ExecuteMenuItemAsync(MeetingTaskbarMenuItem item)
|
||||
{
|
||||
try
|
||||
@@ -256,13 +227,6 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
break;
|
||||
case MeetingTaskbarAction.SwitchProfile:
|
||||
await coordinator.ToggleAsync(item.ProfileName, CancellationToken.None);
|
||||
break;
|
||||
case MeetingTaskbarAction.SelectMicrophone:
|
||||
if (!string.IsNullOrWhiteSpace(item.MicrophoneDeviceId))
|
||||
{
|
||||
microphoneSelection.Select(item.MicrophoneDeviceId);
|
||||
}
|
||||
|
||||
break;
|
||||
case MeetingTaskbarAction.Exit:
|
||||
ExitApplication();
|
||||
@@ -289,47 +253,13 @@ public sealed class UnoTaskbarIconService : IHostedService, IDisposable
|
||||
{
|
||||
return string.Join(
|
||||
"|",
|
||||
FlattenMenuItems(menu.Items).Select(item =>
|
||||
$"{item.Action}:{item.ProfileName}:{item.MicrophoneDeviceId}:{item.IsChecked}:{item.Text}"));
|
||||
}
|
||||
|
||||
private static IEnumerable<MeetingTaskbarMenuItem> FlattenMenuItems(
|
||||
IEnumerable<MeetingTaskbarMenuItem> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
yield return item;
|
||||
if (item.Items is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var child in FlattenMenuItems(item.Items))
|
||||
{
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingAssistantOptions GetActiveProfileOptions(IReadOnlyList<LaunchProfile> profiles)
|
||||
{
|
||||
var activeProfile = coordinator.CurrentStatus.LaunchProfile;
|
||||
return profiles.FirstOrDefault(profile => string.Equals(
|
||||
profile.Name,
|
||||
activeProfile,
|
||||
StringComparison.OrdinalIgnoreCase))?.Options ??
|
||||
profiles.FirstOrDefault(profile => string.Equals(
|
||||
profile.Name,
|
||||
"default",
|
||||
StringComparison.OrdinalIgnoreCase))?.Options ??
|
||||
profiles.FirstOrDefault()?.Options ??
|
||||
new MeetingAssistantOptions();
|
||||
menu.Items.Select(item => $"{item.Action}:{item.ProfileName}:{item.Text}"));
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
var status = coordinator.CurrentStatus;
|
||||
if (MeetingTaskbarExitPolicy.RequiresConfirmation(status.State) && !ConfirmExitDuringProcessing(status.State))
|
||||
if (MeetingTaskbarExitPolicy.RequiresConfirmation(status) && !ConfirmExitDuringProcessing(status.State))
|
||||
{
|
||||
logger.LogInformation("Taskbar exit canceled while meeting state was {State}", status.State);
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.Recording;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Transcription;
|
||||
|
||||
@@ -76,7 +77,7 @@ public sealed class AsrDiagnosticService
|
||||
bool paceAudio,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var chunk in PcmWavAudioChunkReader.ReadChunksAsync(fullPath, cancellationToken: cancellationToken))
|
||||
await foreach (var chunk in ReadPcmWavChunks(fullPath, cancellationToken))
|
||||
{
|
||||
await pipeline.WriteAsync(chunk, cancellationToken);
|
||||
if (paceAudio && chunk.Duration > TimeSpan.Zero)
|
||||
@@ -101,6 +102,40 @@ public sealed class AsrDiagnosticService
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> ReadPcmWavChunks(
|
||||
string path,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var reader = new WaveFileReader(path);
|
||||
try
|
||||
{
|
||||
var format = reader.WaveFormat;
|
||||
if (format.Encoding != WaveFormatEncoding.Pcm || format.BitsPerSample != 16)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Only 16-bit PCM WAV files are supported. Found {format.Encoding} with {format.BitsPerSample} bits per sample.");
|
||||
}
|
||||
|
||||
var buffer = new byte[format.AverageBytesPerSecond];
|
||||
int read;
|
||||
while ((read = await reader.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)) > 0)
|
||||
{
|
||||
yield return new AudioChunk(buffer[..read], format.SampleRate, format.Channels);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
reader.Dispose();
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// NAudio can throw while disposing reader streams from async iterators; diagnostics should not fail after reading all chunks.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AsrDiagnosticResult(string Path, IReadOnlyList<TranscriptionSegment> Segments);
|
||||
|
||||
@@ -17,14 +17,6 @@ public interface IMeetingWorkflowEngine
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<string> TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.AttendeeName ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
@@ -46,14 +38,6 @@ public sealed class NoopMeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
return Task.FromResult(workflowEvent.TranscriptLineText ?? "");
|
||||
}
|
||||
|
||||
public Task<string> TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workflowEvent.AttendeeName ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
@@ -70,8 +54,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
"state.to",
|
||||
"speaker.name",
|
||||
MeetingWorkflowRuleSchema.PropertyTranscriptLine,
|
||||
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker,
|
||||
MeetingWorkflowRuleSchema.PropertyAttendeeName
|
||||
MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker
|
||||
];
|
||||
|
||||
private readonly IMeetingWorkflowRulesProvider rulesProvider;
|
||||
@@ -115,21 +98,6 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
?? "";
|
||||
}
|
||||
|
||||
public async Task<string> TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (workflowEvent.Type != MeetingWorkflowEventType.AttendeeAdded)
|
||||
{
|
||||
throw new ArgumentException("Workflow event must be an attendee added event.", nameof(workflowEvent));
|
||||
}
|
||||
|
||||
return await RunCoreAsync(workflowEvent, options, cancellationToken)
|
||||
?? workflowEvent.AttendeeName
|
||||
?? "";
|
||||
}
|
||||
|
||||
private async Task<string?> RunCoreAsync(
|
||||
MeetingWorkflowEvent workflowEvent,
|
||||
MeetingAssistantOptions options,
|
||||
@@ -139,7 +107,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
var context = new MeetingWorkflowExecutionContext(workflowEvent);
|
||||
if (rules.Count == 0)
|
||||
{
|
||||
return context.ResultValue;
|
||||
return context.TranscriptLine;
|
||||
}
|
||||
|
||||
var meeting = await meetingNoteStore.ReadAsync(
|
||||
@@ -166,18 +134,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
var model = CreateTemplateModel(meeting, context);
|
||||
foreach (var step in rule.Steps)
|
||||
{
|
||||
if (workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded &&
|
||||
!IsAttendeeTransformStep(step))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"attendee_added workflow rules can only transform attendee.name with set_property.");
|
||||
}
|
||||
|
||||
var stepChanged = await ApplyStepAsync(
|
||||
step,
|
||||
meeting,
|
||||
context,
|
||||
options,
|
||||
model,
|
||||
cancellationToken);
|
||||
ruleNoteChanged |= stepChanged;
|
||||
@@ -216,7 +176,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return context.ResultValue;
|
||||
return context.TranscriptLine;
|
||||
}
|
||||
|
||||
private static bool MatchesTrigger(
|
||||
@@ -269,41 +229,9 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (trigger.AttendeeAdded is not null)
|
||||
{
|
||||
return workflowEvent.Type == MeetingWorkflowEventType.AttendeeAdded &&
|
||||
MatchesAttendeeAddedTrigger(trigger.AttendeeAdded, workflowEvent.AttendeeName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesAttendeeAddedTrigger(
|
||||
MeetingWorkflowAttendeeAddedTrigger trigger,
|
||||
string? attendeeName)
|
||||
{
|
||||
var attendee = attendeeName ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(trigger.EqualsValue) &&
|
||||
!string.Equals(attendee, trigger.EqualsValue.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trigger.Contains) &&
|
||||
!attendee.Contains(trigger.Contains.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trigger.Regex) &&
|
||||
!Regex.IsMatch(attendee, trigger.Regex, RegexOptions.IgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EvaluateConditions(
|
||||
IReadOnlyList<MeetingWorkflowCondition> conditions,
|
||||
MeetingNote meeting,
|
||||
@@ -353,14 +281,14 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
}
|
||||
|
||||
expression.Functions["contains"] = args =>
|
||||
Contains(args.Evaluate(0), Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture));
|
||||
Contains(args[0].Evaluate(), Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture));
|
||||
expression.Functions["starts_with"] = args =>
|
||||
Convert.ToString(args.Evaluate(0), CultureInfo.InvariantCulture)?.StartsWith(
|
||||
Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture) ?? "",
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.StartsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
expression.Functions["ends_with"] = args =>
|
||||
Convert.ToString(args.Evaluate(0), CultureInfo.InvariantCulture)?.EndsWith(
|
||||
Convert.ToString(args.Evaluate(1), CultureInfo.InvariantCulture) ?? "",
|
||||
Convert.ToString(args[0].Evaluate(), CultureInfo.InvariantCulture)?.EndsWith(
|
||||
Convert.ToString(args[1].Evaluate(), CultureInfo.InvariantCulture) ?? "",
|
||||
StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
return Convert.ToBoolean(expression.Evaluate(), CultureInfo.InvariantCulture);
|
||||
@@ -370,7 +298,6 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
MeetingWorkflowStep step,
|
||||
MeetingNote meeting,
|
||||
MeetingWorkflowExecutionContext context,
|
||||
MeetingAssistantOptions options,
|
||||
MeetingWorkflowTemplateModel model,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -378,11 +305,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
switch (step.Uses.Trim().ToLowerInvariant())
|
||||
{
|
||||
case MeetingWorkflowRuleSchema.StepAddAttendee:
|
||||
var attendee = await TransformAttendeeAsync(
|
||||
MeetingWorkflowEvent.AttendeeAdded(context.Event.Artifacts, value),
|
||||
options,
|
||||
cancellationToken);
|
||||
return AddUnique(meeting.Frontmatter.Attendees, attendee, MeetingAttendeeNames.NormalizeDisplayName);
|
||||
return AddUnique(meeting.Frontmatter.Attendees, value, MeetingAttendeeNames.NormalizeDisplayName);
|
||||
case MeetingWorkflowRuleSchema.StepRemoveAttendee:
|
||||
return RemoveValue(meeting.Frontmatter.Attendees, value);
|
||||
case MeetingWorkflowRuleSchema.StepAddProject:
|
||||
@@ -396,9 +319,6 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
case MeetingWorkflowRuleSchema.StepSetProperty when IsTranscriptLineProperty(step.Property ?? step.Name):
|
||||
SetTranscriptLine(context, value);
|
||||
return false;
|
||||
case MeetingWorkflowRuleSchema.StepSetProperty when IsAttendeeNameProperty(step.Property ?? step.Name):
|
||||
SetAttendeeName(context, value);
|
||||
return false;
|
||||
case MeetingWorkflowRuleSchema.StepSetProperty:
|
||||
return SetProperty(meeting, step.Property ?? step.Name, value);
|
||||
default:
|
||||
@@ -450,39 +370,11 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
context.TranscriptLine = value;
|
||||
}
|
||||
|
||||
private static void SetAttendeeName(
|
||||
MeetingWorkflowExecutionContext context,
|
||||
string value)
|
||||
{
|
||||
if (context.Event.Type != MeetingWorkflowEventType.AttendeeAdded)
|
||||
{
|
||||
throw new InvalidOperationException("The attendee.name property can only be set during attendee_added events.");
|
||||
}
|
||||
|
||||
if (string.Equals(context.AttendeeName, value, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.AttendeeName = value;
|
||||
}
|
||||
|
||||
private static bool IsTranscriptLineProperty(string? property)
|
||||
{
|
||||
return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyTranscriptLine);
|
||||
}
|
||||
|
||||
private static bool IsAttendeeNameProperty(string? property)
|
||||
{
|
||||
return MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName);
|
||||
}
|
||||
|
||||
private static bool IsAttendeeTransformStep(MeetingWorkflowStep step)
|
||||
{
|
||||
return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) &&
|
||||
IsAttendeeNameProperty(step.Property ?? step.Name);
|
||||
}
|
||||
|
||||
private static bool AddUnique(
|
||||
List<string> values,
|
||||
string value,
|
||||
@@ -564,8 +456,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
["state.to"] = workflowEvent.ToState is { } to ? MeetingWorkflowStateNames.ToRuleName(to) : "",
|
||||
["speaker.name"] = workflowEvent.SpeakerName,
|
||||
[MeetingWorkflowRuleSchema.PropertyTranscriptLine] = context.TranscriptLine ?? "",
|
||||
[MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? "",
|
||||
[MeetingWorkflowRuleSchema.PropertyAttendeeName] = context.AttendeeName ?? ""
|
||||
[MeetingWorkflowRuleSchema.ParameterTranscriptSpeaker] = workflowEvent.SpeakerName ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
@@ -589,10 +480,7 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
: new MeetingWorkflowSpeakerModel(workflowEvent.SpeakerName),
|
||||
new MeetingWorkflowTranscriptModel(
|
||||
context.TranscriptLine ?? "",
|
||||
workflowEvent.SpeakerName ?? ""),
|
||||
string.IsNullOrWhiteSpace(context.AttendeeName)
|
||||
? null
|
||||
: new MeetingWorkflowAttendeeModel(context.AttendeeName));
|
||||
workflowEvent.SpeakerName ?? ""));
|
||||
}
|
||||
|
||||
private sealed class MeetingWorkflowExecutionContext
|
||||
@@ -601,23 +489,10 @@ public sealed class MeetingWorkflowEngine : IMeetingWorkflowEngine
|
||||
{
|
||||
Event = workflowEvent;
|
||||
TranscriptLine = workflowEvent.TranscriptLineText;
|
||||
AttendeeName = workflowEvent.AttendeeName;
|
||||
}
|
||||
|
||||
public MeetingWorkflowEvent Event { get; }
|
||||
|
||||
public string? TranscriptLine { get; set; }
|
||||
|
||||
public string? AttendeeName { get; set; }
|
||||
|
||||
public string? ResultValue
|
||||
{
|
||||
get
|
||||
{
|
||||
return Event.Type == MeetingWorkflowEventType.AttendeeAdded
|
||||
? AttendeeName
|
||||
: TranscriptLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ public enum MeetingWorkflowEventType
|
||||
Created,
|
||||
StateTransition,
|
||||
SpeakerIdentified,
|
||||
TranscriptLine,
|
||||
AttendeeAdded
|
||||
TranscriptLine
|
||||
}
|
||||
|
||||
public sealed record MeetingWorkflowEvent(
|
||||
@@ -17,8 +16,7 @@ public sealed record MeetingWorkflowEvent(
|
||||
AssistantContextState? FromState = null,
|
||||
AssistantContextState? ToState = null,
|
||||
string? SpeakerName = null,
|
||||
string? TranscriptLineText = null,
|
||||
string? AttendeeName = null)
|
||||
string? TranscriptLineText = null)
|
||||
{
|
||||
public static MeetingWorkflowEvent Created(MeetingSessionArtifacts artifacts)
|
||||
{
|
||||
@@ -51,14 +49,4 @@ public sealed record MeetingWorkflowEvent(
|
||||
SpeakerName: speakerName,
|
||||
TranscriptLineText: line);
|
||||
}
|
||||
|
||||
public static MeetingWorkflowEvent AttendeeAdded(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string attendeeName)
|
||||
{
|
||||
return new MeetingWorkflowEvent(
|
||||
MeetingWorkflowEventType.AttendeeAdded,
|
||||
artifacts,
|
||||
AttendeeName: attendeeName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,6 @@ public sealed class MeetingWorkflowTrigger
|
||||
|
||||
[YamlMember(Alias = "transcript_line")]
|
||||
public MeetingWorkflowTranscriptLineTrigger? TranscriptLine { get; set; }
|
||||
|
||||
[YamlMember(Alias = "attendee_added")]
|
||||
public MeetingWorkflowAttendeeAddedTrigger? AttendeeAdded { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowStateTransitionTrigger
|
||||
@@ -63,18 +60,6 @@ public sealed class MeetingWorkflowTranscriptLineTrigger
|
||||
public string? Speaker { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowAttendeeAddedTrigger
|
||||
{
|
||||
[YamlMember(Alias = "equals")]
|
||||
public string? EqualsValue { get; set; }
|
||||
|
||||
[YamlMember(Alias = "contains")]
|
||||
public string? Contains { get; set; }
|
||||
|
||||
[YamlMember(Alias = "regex")]
|
||||
public string? Regex { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MeetingWorkflowCondition
|
||||
{
|
||||
[YamlMember(Alias = "condition")]
|
||||
@@ -109,8 +94,7 @@ public sealed record MeetingWorkflowTemplateModel(
|
||||
MeetingWorkflowMeetingModel Meeting,
|
||||
MeetingWorkflowEventModel Event,
|
||||
MeetingWorkflowSpeakerModel? Speaker,
|
||||
MeetingWorkflowTranscriptModel? Transcript,
|
||||
MeetingWorkflowAttendeeModel? Attendee);
|
||||
MeetingWorkflowTranscriptModel? Transcript);
|
||||
|
||||
public sealed record MeetingWorkflowMeetingModel(
|
||||
string Title,
|
||||
@@ -127,8 +111,6 @@ public sealed record MeetingWorkflowSpeakerModel(string Name);
|
||||
|
||||
public sealed record MeetingWorkflowTranscriptModel(string Line, string Speaker);
|
||||
|
||||
public sealed record MeetingWorkflowAttendeeModel(string Name);
|
||||
|
||||
internal static class MeetingWorkflowStateNames
|
||||
{
|
||||
public static string ToRuleName(AssistantContextState state)
|
||||
|
||||
@@ -12,15 +12,13 @@ internal static class MeetingWorkflowRuleSchema
|
||||
public const string PropertyMeetingTitle = "meeting.title";
|
||||
public const string PropertyTranscriptLine = "transcript.line";
|
||||
public const string ParameterTranscriptSpeaker = "transcript.speaker";
|
||||
public const string PropertyAttendeeName = "attendee.name";
|
||||
|
||||
public static readonly string[] SupportedTriggerKeys =
|
||||
[
|
||||
"created",
|
||||
"state_transition",
|
||||
"speaker_identified",
|
||||
"transcript_line",
|
||||
"attendee_added"
|
||||
"transcript_line"
|
||||
];
|
||||
|
||||
public static readonly string[] SupportedConditionKeys =
|
||||
@@ -44,8 +42,7 @@ internal static class MeetingWorkflowRuleSchema
|
||||
[
|
||||
PropertyTitle,
|
||||
PropertyMeetingTitle,
|
||||
PropertyTranscriptLine,
|
||||
PropertyAttendeeName
|
||||
PropertyTranscriptLine
|
||||
];
|
||||
|
||||
public static bool IsStep(string? value, string step)
|
||||
|
||||
@@ -59,8 +59,7 @@ internal static class MeetingWorkflowRulesValidator
|
||||
trigger.Created is not null,
|
||||
trigger.StateTransition is not null,
|
||||
trigger.SpeakerIdentified is not null,
|
||||
trigger.TranscriptLine is not null,
|
||||
trigger.AttendeeAdded is not null);
|
||||
trigger.TranscriptLine is not null);
|
||||
if (configuredCount == 0)
|
||||
{
|
||||
errors.Add($"{ruleName} on[{triggerIndex + 1}] must contain one supported trigger: {JoinAllowed(MeetingWorkflowRuleSchema.SupportedTriggerKeys)}.");
|
||||
@@ -138,12 +137,6 @@ internal static class MeetingWorkflowRulesValidator
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasAttendeeAddedTrigger(rule) &&
|
||||
!IsAttendeeNameSetPropertyStep(step))
|
||||
{
|
||||
errors.Add($"{stepPath} on attendee_added rules can only transform the attendee with 'set_property' property 'attendee.name'.");
|
||||
}
|
||||
|
||||
if (MeetingWorkflowRuleSchema.IsStep(uses, MeetingWorkflowRuleSchema.StepSetProperty))
|
||||
{
|
||||
var property = step.Property ?? step.Name;
|
||||
@@ -160,11 +153,6 @@ internal static class MeetingWorkflowRulesValidator
|
||||
{
|
||||
errors.Add($"{stepPath} can set 'transcript.line' only on rules with a transcript_line trigger.");
|
||||
}
|
||||
else if (MeetingWorkflowRuleSchema.IsProperty(property, MeetingWorkflowRuleSchema.PropertyAttendeeName) &&
|
||||
!HasAttendeeAddedTrigger(rule))
|
||||
{
|
||||
errors.Add($"{stepPath} can set 'attendee.name' only on rules with an attendee_added trigger.");
|
||||
}
|
||||
}
|
||||
|
||||
if (step.Value is { } value &&
|
||||
@@ -193,17 +181,6 @@ internal static class MeetingWorkflowRulesValidator
|
||||
return rule.On.Any(static trigger => trigger.TranscriptLine is not null);
|
||||
}
|
||||
|
||||
private static bool HasAttendeeAddedTrigger(MeetingWorkflowRule rule)
|
||||
{
|
||||
return rule.On.Any(static trigger => trigger.AttendeeAdded is not null);
|
||||
}
|
||||
|
||||
private static bool IsAttendeeNameSetPropertyStep(MeetingWorkflowStep step)
|
||||
{
|
||||
return MeetingWorkflowRuleSchema.IsStep(step.Uses, MeetingWorkflowRuleSchema.StepSetProperty) &&
|
||||
MeetingWorkflowRuleSchema.IsProperty(step.Property ?? step.Name, MeetingWorkflowRuleSchema.PropertyAttendeeName);
|
||||
}
|
||||
|
||||
private static int CountConfigured(params bool[] values)
|
||||
{
|
||||
return values.Count(static value => value);
|
||||
@@ -229,8 +206,7 @@ internal static class MeetingWorkflowRulesValidator
|
||||
new MeetingWorkflowSpeakerModel("Ada Lovelace"),
|
||||
new MeetingWorkflowTranscriptModel(
|
||||
"[00:00:01] Ada Lovelace: Validation line.",
|
||||
"Ada Lovelace"),
|
||||
new MeetingWorkflowAttendeeModel("Ada Lovelace"));
|
||||
"Ada Lovelace"));
|
||||
}
|
||||
|
||||
private static string FormatRazorValidationMessage(Exception exception)
|
||||
|
||||
@@ -10,66 +10,9 @@ public sealed record WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole Role,
|
||||
string Content);
|
||||
|
||||
public enum WorkflowRulesEditorConversationItemKind
|
||||
{
|
||||
Message,
|
||||
Activity
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind Kind,
|
||||
WorkflowRulesEditorChatMessage? Message,
|
||||
string Content,
|
||||
IReadOnlyList<string>? ActivityLines = null)
|
||||
{
|
||||
public bool IsExpanded { get; set; }
|
||||
|
||||
public static WorkflowRulesEditorConversationItem ChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
return new WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind.Message,
|
||||
message,
|
||||
message.Content);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorConversationItem Activity(string content, IReadOnlyList<string> activityLines)
|
||||
{
|
||||
return new WorkflowRulesEditorConversationItem(
|
||||
WorkflowRulesEditorConversationItemKind.Activity,
|
||||
null,
|
||||
content,
|
||||
activityLines);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorChatResult(string Response);
|
||||
|
||||
public enum WorkflowRulesEditorActivityKind
|
||||
{
|
||||
Status,
|
||||
ToolCall,
|
||||
Thinking
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorActivityUpdate(
|
||||
WorkflowRulesEditorActivityKind Kind,
|
||||
string Text)
|
||||
{
|
||||
public static WorkflowRulesEditorActivityUpdate Status(string text)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Status, text);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorActivityUpdate ToolCall(string toolName)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.ToolCall, toolName);
|
||||
}
|
||||
|
||||
public static WorkflowRulesEditorActivityUpdate Thinking(string text)
|
||||
{
|
||||
return new WorkflowRulesEditorActivityUpdate(WorkflowRulesEditorActivityKind.Thinking, text);
|
||||
}
|
||||
}
|
||||
public sealed record WorkflowRulesEditorChatResult(
|
||||
string Response,
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> Conversation);
|
||||
|
||||
public interface IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
@@ -77,5 +20,5 @@ public interface IWorkflowRulesEditorChatPipeline
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null);
|
||||
Action<string>? statusChanged = null);
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken,
|
||||
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
||||
Action<string>? statusChanged = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
return new WorkflowRulesEditorChatResult("");
|
||||
return new WorkflowRulesEditorChatResult("", conversation);
|
||||
}
|
||||
|
||||
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
@@ -85,35 +85,33 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
.ToList();
|
||||
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
||||
|
||||
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
||||
agentOptions,
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions: null,
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||
using var chatClient = LiteLlmResponsesChatClient.Create(
|
||||
agentOptions,
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser: true,
|
||||
retrying: () => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Status("Reconnecting...")),
|
||||
reasoningSummaryChanged: text => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Thinking(text)));
|
||||
retrying: () => statusChanged?.Invoke("Reconnecting..."));
|
||||
var functionClient = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(loggerFactory, client =>
|
||||
{
|
||||
client.FunctionInvoker = async (context, token) =>
|
||||
{
|
||||
if (context.CallContent.Exception is null)
|
||||
{
|
||||
activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name));
|
||||
}
|
||||
|
||||
return await FunctionInvocationGuard.InvokeAsync(context, token);
|
||||
};
|
||||
})
|
||||
.UseFunctionInvocation(loggerFactory)
|
||||
.Build();
|
||||
|
||||
var response = await functionClient.GetResponseAsync(
|
||||
@@ -123,7 +121,11 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
var responseText = string.IsNullOrWhiteSpace(response.Text)
|
||||
? "(No response text returned.)"
|
||||
: response.Text.Trim();
|
||||
return new WorkflowRulesEditorChatResult(responseText);
|
||||
var nextConversation = conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim()))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText))
|
||||
.ToList();
|
||||
return new WorkflowRulesEditorChatResult(responseText, nextConversation);
|
||||
}
|
||||
|
||||
private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
@@ -390,6 +392,18 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
|
||||
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
|
||||
}
|
||||
|
||||
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => "none",
|
||||
ReasoningEffortOption.Low => "low",
|
||||
ReasoningEffortOption.High => "high",
|
||||
ReasoningEffortOption.ExtraHigh => "xhigh",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class WorkflowRulesEditorChatViewModel
|
||||
{
|
||||
private const string ThinkingPlaceholder = "Thinking...";
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
|
||||
public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline)
|
||||
@@ -13,15 +11,13 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
this.pipeline = pipeline;
|
||||
}
|
||||
|
||||
public ObservableCollection<WorkflowRulesEditorConversationItem> Messages { get; } = [];
|
||||
|
||||
public ObservableCollection<string> ActivityMessages { get; } = [];
|
||||
public ObservableCollection<WorkflowRulesEditorChatMessage> Messages { get; } = [];
|
||||
|
||||
public string Draft { get; set; } = "";
|
||||
|
||||
public bool IsThinking { get; private set; }
|
||||
|
||||
public string ActivityMessage { get; private set; } = ThinkingPlaceholder;
|
||||
public string ActivityMessage { get; private set; } = "Thinking...";
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
@@ -34,19 +30,10 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
}
|
||||
|
||||
Draft = "";
|
||||
var startedAt = Stopwatch.GetTimestamp();
|
||||
var activityLines = new List<string>();
|
||||
var hasVisibleThinking = false;
|
||||
var activityContext = SynchronizationContext.Current;
|
||||
var priorConversation = Messages
|
||||
.Select(item => item.Message)
|
||||
.OfType<WorkflowRulesEditorChatMessage>()
|
||||
.ToList();
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt)));
|
||||
ActivityMessages.Clear();
|
||||
var priorConversation = Messages.ToList();
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
|
||||
IsThinking = true;
|
||||
ActivityMessage = ThinkingPlaceholder;
|
||||
ActivityMessage = "Thinking...";
|
||||
OnChanged();
|
||||
|
||||
try
|
||||
@@ -55,121 +42,36 @@ public sealed class WorkflowRulesEditorChatViewModel
|
||||
priorConversation,
|
||||
prompt,
|
||||
cancellationToken,
|
||||
update => hasVisibleThinking |= ApplyActivityUpdate(activityContext, update, activityLines));
|
||||
AddCompletedActivity(startedAt, activityLines, hasVisibleThinking);
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
string.IsNullOrWhiteSpace(result.Response)
|
||||
? "(No response text returned.)"
|
||||
: result.Response.Trim())));
|
||||
SetActivityMessage);
|
||||
Messages.Clear();
|
||||
foreach (var message in result.Conversation)
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
AddCompletedActivity(startedAt, activityLines, hasVisibleThinking);
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.ChatMessage(
|
||||
new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Meeting Summary Agent failed: {exception.Message}")));
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Settings and logs failed: {exception.Message}"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsThinking = false;
|
||||
ActivityMessages.Clear();
|
||||
ActivityMessage = ThinkingPlaceholder;
|
||||
ActivityMessage = "Thinking...";
|
||||
OnChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bool ApplyActivityUpdate(
|
||||
SynchronizationContext? context,
|
||||
WorkflowRulesEditorActivityUpdate update,
|
||||
List<string> activityLines)
|
||||
private void SetActivityMessage(string message)
|
||||
{
|
||||
if (context is null || SynchronizationContext.Current == context)
|
||||
if (!IsThinking || string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return ApplyActivityUpdate(update, activityLines);
|
||||
}
|
||||
|
||||
var hasVisibleThinking = false;
|
||||
Exception? exception = null;
|
||||
context.Send(
|
||||
_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
hasVisibleThinking = ApplyActivityUpdate(update, activityLines);
|
||||
}
|
||||
catch (Exception caught)
|
||||
{
|
||||
exception = caught;
|
||||
}
|
||||
},
|
||||
null);
|
||||
|
||||
if (exception is not null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return hasVisibleThinking;
|
||||
}
|
||||
|
||||
private bool ApplyActivityUpdate(
|
||||
WorkflowRulesEditorActivityUpdate update,
|
||||
List<string> activityLines)
|
||||
{
|
||||
if (!IsThinking || string.IsNullOrWhiteSpace(update.Text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasVisibleThinking = false;
|
||||
if (update.Kind == WorkflowRulesEditorActivityKind.ToolCall)
|
||||
{
|
||||
var line = $"Called tool: {update.Text.Trim()}";
|
||||
ActivityMessages.Add(line);
|
||||
activityLines.Add(line);
|
||||
}
|
||||
else if (update.Kind == WorkflowRulesEditorActivityKind.Thinking)
|
||||
{
|
||||
var line = update.Text.Trim();
|
||||
ActivityMessages.Add(line);
|
||||
activityLines.Add(line);
|
||||
hasVisibleThinking = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var line = update.Text.Trim();
|
||||
ActivityMessage = line;
|
||||
activityLines.Add(line);
|
||||
return;
|
||||
}
|
||||
|
||||
ActivityMessage = message;
|
||||
OnChanged();
|
||||
return hasVisibleThinking;
|
||||
}
|
||||
|
||||
private void AddCompletedActivity(
|
||||
long startedAt,
|
||||
IReadOnlyList<string> activityLines,
|
||||
bool hasVisibleThinking)
|
||||
{
|
||||
var completedActivityLines = hasVisibleThinking
|
||||
? activityLines.ToArray()
|
||||
: new[] { ThinkingPlaceholder }.Concat(activityLines).ToArray();
|
||||
Messages.Add(WorkflowRulesEditorConversationItem.Activity(
|
||||
$"Worked for {FormatDuration(Stopwatch.GetElapsedTime(startedAt))}",
|
||||
completedActivityLines));
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalMinutes >= 1)
|
||||
{
|
||||
return $"{(int)duration.TotalMinutes}m {duration.Seconds}s";
|
||||
}
|
||||
|
||||
return $"{Math.Max(0, (int)Math.Round(duration.TotalSeconds))}s";
|
||||
}
|
||||
|
||||
private void OnChanged()
|
||||
|
||||
@@ -60,7 +60,7 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
|
||||
"Log tools can read and search the current application-owned log file and four rotated older files under the temp log folder." + Environment.NewLine + Environment.NewLine +
|
||||
"Spec tools can search and read copied OpenSpec markdown files from openspec/specs." + Environment.NewLine + Environment.NewLine +
|
||||
"Project tools can create project folders and read, write, list, and search files in configured projects." + Environment.NewLine + Environment.NewLine +
|
||||
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes. Treat each assistant context file as meeting-specific memory. When asked to fix or investigate a meeting or summary, read the matching assistant context for clues about problems, missing information, assumptions, prior fixes, and conclusions. After completing repairs, use write_context to append a concise record of your fixes and conclusions to that assistant context for future work on the meeting." + Environment.NewLine + Environment.NewLine +
|
||||
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes." + Environment.NewLine + Environment.NewLine +
|
||||
"Diagnostic tools mirror the local health, recording status, Outlook metadata, speaker identity merge, workflow reload, and ASR diagnostic endpoints without requiring HTTP." + Environment.NewLine + Environment.NewLine +
|
||||
"Speaker identity tools can search/list/read/update/delete/merge identities, refuse sampleless identity creation, list/read/delete identity samples, and queue a sample for local playback. Do not delete the last sample from an identity; delete the identity instead." + Environment.NewLine + Environment.NewLine +
|
||||
"Workflow rules reference documentation:" + Environment.NewLine +
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal sealed class WpfWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
internal const string WindowTitle = "Meeting Summary Agent";
|
||||
internal const string WindowTitle = "Settings and logs";
|
||||
|
||||
private readonly IServiceProvider services;
|
||||
private readonly WorkflowRulesEditorMarkdownLinkResolver linkResolver;
|
||||
@@ -288,21 +288,19 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Children.Clear();
|
||||
foreach (var item in viewModel.Messages)
|
||||
foreach (var message in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Children.Add(item.Kind == WorkflowRulesEditorConversationItemKind.Activity
|
||||
? CreateActivityExpander(item)
|
||||
: CreateMessageCard(item.Message!));
|
||||
conversationPanel.Children.Add(CreateMessageCard(message));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
foreach (var activity in viewModel.ActivityMessages)
|
||||
conversationPanel.Children.Add(new TextBlock
|
||||
{
|
||||
conversationPanel.Children.Add(CreateActivityLine(activity));
|
||||
}
|
||||
|
||||
conversationPanel.Children.Add(CreateActivityLine(viewModel.ActivityMessage));
|
||||
Text = viewModel.ActivityMessage,
|
||||
Foreground = MutedText,
|
||||
Margin = new Thickness(4, 2, 4, 2)
|
||||
});
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
@@ -340,43 +338,6 @@ internal sealed class WorkflowRulesEditorWpfWindow : Window
|
||||
return card;
|
||||
}
|
||||
|
||||
private static Expander CreateActivityExpander(WorkflowRulesEditorConversationItem item)
|
||||
{
|
||||
var details = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Vertical,
|
||||
Margin = new Thickness(16, 2, 4, 6)
|
||||
};
|
||||
|
||||
foreach (var line in item.ActivityLines ?? [])
|
||||
{
|
||||
details.Children.Add(CreateActivityLine(line));
|
||||
}
|
||||
|
||||
var expander = new Expander
|
||||
{
|
||||
Header = item.Content,
|
||||
Content = details,
|
||||
IsExpanded = item.IsExpanded,
|
||||
Foreground = MutedText,
|
||||
Background = Brushes.Transparent,
|
||||
Margin = new Thickness(4, 0, 4, 8)
|
||||
};
|
||||
expander.Expanded += (_, _) => item.IsExpanded = true;
|
||||
expander.Collapsed += (_, _) => item.IsExpanded = false;
|
||||
return expander;
|
||||
}
|
||||
|
||||
private static TextBlock CreateActivityLine(string text)
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
Foreground = MutedText,
|
||||
Margin = new Thickness(4, 2, 4, 2)
|
||||
};
|
||||
}
|
||||
|
||||
private static Style CreateSendButtonStyle()
|
||||
{
|
||||
var style = new Style(typeof(Button));
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"TranscriptionProvider": "azure-speech",
|
||||
"SampleRate": 16000,
|
||||
"Channels": 1,
|
||||
"MicrophoneDeviceId": "",
|
||||
"MicrophoneMixGain": 1,
|
||||
"SystemAudioMixGain": 1,
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
@@ -172,7 +171,6 @@
|
||||
"Endpoint": "http://127.0.0.1:4021",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "chatgpt/gpt-5.5",
|
||||
"UseStreaming": true,
|
||||
"EnableThinking": true,
|
||||
"ReasoningEffort": "Medium",
|
||||
"ReconnectionAttempts": 2,
|
||||
|
||||
@@ -67,9 +67,8 @@ The main local endpoints are:
|
||||
- `POST /diagnostics/workflow/rules-editor/show`
|
||||
- `POST /meetings/current/summary/run`
|
||||
- `POST` or `GET /meetings/summary/retry`
|
||||
- `POST` or `GET /meetings/screenshot-ocr/retry`
|
||||
|
||||
Stopping a recording stops new audio capture but lets that run continue transcription drain, speaker processing, meeting-note image OCR, screenshot OCR waits, summary generation, and final artifact updates. A later recording can start while an older stopped run is still finalizing; each run keeps isolated artifact paths and options.
|
||||
Stopping a recording stops new audio capture but lets that run continue transcription drain, speaker processing, screenshot OCR waits, summary generation, and final artifact updates. A later recording can start while an older stopped run is still finalizing; each run keeps isolated artifact paths and options.
|
||||
|
||||
## Data And Side Effects
|
||||
|
||||
@@ -100,7 +99,6 @@ Important settings:
|
||||
|
||||
- `Vault`: controls the Obsidian vault root and the relative folders for notes, transcripts, summaries, assistant context, project knowledge, and dictation words.
|
||||
- `Recording:TranscriptionProvider`: selects `azure-speech`, `funasr`, or `whisper-local`.
|
||||
- `Recording:MicrophoneDeviceId`: optionally pins the Windows microphone endpoint; blank follows the Windows default, and the tray icon can override it for later starts.
|
||||
- `Recording:TemporaryRecordingsFolder`: controls temporary mixed WAV storage.
|
||||
- `Recording:InactivitySafeguard`: prompts and then auto-stops forgotten silent recordings without aborting artifacts.
|
||||
- `LaunchProfiles`: overlays named profile settings onto the default profile; profile hotkeys must be distinct.
|
||||
@@ -108,7 +106,7 @@ Important settings:
|
||||
- `CalendarRecordingPrompts`: enables Outlook Classic Teams-start prompts on Windows.
|
||||
- `Screenshots`: controls the capture hotkey, attachment folder, and optional OCR/vision model.
|
||||
- `Agent`: configures the OpenAI-compatible summary/project agent endpoint, model, retries, output limits, and compaction.
|
||||
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched assistant.
|
||||
- `WorkflowRulesEditor`: optionally overrides the agent settings for the tray-launched settings/logs assistant.
|
||||
|
||||
Required or commonly used secrets:
|
||||
|
||||
@@ -125,14 +123,14 @@ See `docs/meeting-assistant-configuration.md` for the full configuration referen
|
||||
- **Azure AI Speech**: default checked-in ASR path, live diarized conversation transcription, and speaker identity matching.
|
||||
- **FunASR**: optional WebSocket streaming ASR. When managed backend startup is enabled, the app pulls and runs the configured Docker image as `meeting-assistant-funasr` on port `10095`.
|
||||
- **Whisper.NET plus pyannote**: optional local Whisper fallback and Docker-backed final diarization.
|
||||
- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, the tray-launched assistant, and retry flows.
|
||||
- **LiteLLM/OpenAI-compatible Responses endpoint**: summary generation, screenshot OCR fallback, project tools, settings/logs assistant, and retry flows.
|
||||
- **Docker Desktop or compatible Docker CLI**: required only for managed FunASR and pyannote paths.
|
||||
|
||||
## Workflow Rules And Agents
|
||||
|
||||
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, transcript-line writes, and added attendees. They can add/remove attendees, set supported properties, add context, add projects, rewrite a transcript line, and transform an attendee name before it is stored.
|
||||
Meeting-specific automation lives in a local YAML file, not in committed personal rules. Rules can trigger on meeting creation, assistant-context state transitions, identified speakers, and transcript-line writes. They can add/remove attendees, set supported properties, add context, add projects, and rewrite a transcript line before persistence.
|
||||
|
||||
The tray menu exposes `Open agent`, which opens the `Meeting Summary Agent` window. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
|
||||
The tray menu exposes the settings/logs assistant. It can edit workflow rules with validation, inspect logs and health/status, manage speaker identities and samples, run ASR diagnostics, and read/write scoped meeting/project artifacts through explicit tools.
|
||||
|
||||
Detailed workflow syntax and extension guidance live in `docs/meeting-workflow-engine.md`.
|
||||
|
||||
@@ -158,7 +156,7 @@ Documentation-only README maintenance does not need a new OpenSpec change.
|
||||
- Treat the app as live user work. Check `/recording/status` before restarting, killing processes, deleting runtime files, or running scripts that might take over port `5090`.
|
||||
- Abort is destructive for the active run: it removes that meeting's note, transcript, assistant context, summary if present, and linked screenshot attachments, and it skips summary generation.
|
||||
- Too-short or empty normal stops can also delete generated artifacts instead of producing summaries.
|
||||
- Summary and screenshot OCR retry links use `Api:PublicBaseUrl`, defaulting to `http://localhost:5090`.
|
||||
- Summary retry links use `Api:PublicBaseUrl`, defaulting to `http://localhost:5090`.
|
||||
- The workflow reload endpoint reloads configuration for future workflow reads, but a meeting run that already captured options may continue with those captured options.
|
||||
- Public hostnames and homelab ingress are intentionally out of scope for this repo.
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ This example is abbreviated so the most common shape is readable. The checked-in
|
||||
"TranscriptionProvider": "funasr",
|
||||
"SampleRate": 16000,
|
||||
"Channels": 1,
|
||||
"MicrophoneDeviceId": "",
|
||||
"MicrophoneMixGain": 1,
|
||||
"SystemAudioMixGain": 1,
|
||||
"StopProcessingTimeout": "00:10:00",
|
||||
@@ -116,7 +115,6 @@ The default profile is always named `default`. Non-default profile hotkeys are r
|
||||
| --- | --- |
|
||||
| `SampleRate` | PCM sample rate for captured audio. The current providers expect 16000 Hz. |
|
||||
| `Channels` | PCM channel count for the mixed stream. The current providers expect mono (`1`). |
|
||||
| `MicrophoneDeviceId` | Optional Windows microphone capture endpoint id. Blank uses the Windows default capture endpoint. |
|
||||
| `MicrophoneMixGain` | Gain applied to cleaned microphone samples before final mixing. |
|
||||
| `SystemAudioMixGain` | Gain applied to system loopback samples before final mixing. |
|
||||
| `StopProcessingTimeout` | Maximum time to wait for post-stop processing such as transcription drain, finalization, OCR wait, and summary handoff. |
|
||||
@@ -131,8 +129,6 @@ The default profile is always named `default`. Non-default profile hotkeys are r
|
||||
|
||||
During recording, Meeting Assistant captures microphone and system loopback separately, buffers both streams to align samples, cleans the microphone stream through a local adaptive echo canceller using loopback as the far-end reference, then mixes the cleaned microphone and system streams into the normal 16 kHz mono PCM chunks. If one source is quiet beyond the alignment timeout, the available source is mixed with synthetic silence so microphone-only speech and system-only playback keep flowing to transcription.
|
||||
|
||||
On Windows, `Recording:MicrophoneDeviceId` can pin capture to a specific active microphone endpoint id. Leave it blank to follow the Windows default capture endpoint. The tray icon menu also exposes `Microphone`, listing active microphone endpoints with the effective endpoint checked. Selecting a microphone there overrides the configured/default microphone for later recording starts until another microphone is selected or the process exits.
|
||||
|
||||
`Recording:MicrophoneMixGain` and `Recording:SystemAudioMixGain` are applied during the final mix and default to `1`. `Recording:TemporaryRecordingsFolder` controls where the temporary mixed WAV is written while the run is active. Temporary WAV files are deleted after the run completes, and stale temporary recordings from interrupted runs are deleted when the application starts. If an Azure Speech meeting cannot drain transcription before `Recording:StopProcessingTimeout`, Meeting Assistant keeps the WAV and writes a durable backlog item under `TemporaryRecordingsFolder\offline-transcription-backlog`. The background backlog worker retries those queued meetings, replays each WAV through a fresh speech pipeline, rewrites the original transcript, completes meeting metadata and summary generation, then removes the backlog item and WAV.
|
||||
|
||||
`Recording:MaxMetadataAttendeeImportCount` limits how many attendees Outlook metadata enrichment imports into meeting-note frontmatter. The default is `30`; when an appointment has more attendees than that, Meeting Assistant still imports title, agenda, and scheduled end time, but leaves attendees empty because large invites are usually presentation-style meetings.
|
||||
@@ -305,7 +301,7 @@ See `docs/meeting-workflow-engine.md` for the detailed YAML format, supported va
|
||||
|
||||
`CalendarRecordingPrompts` controls optional Outlook Classic calendar prompts for starting recordings. It is enabled by default on Windows.
|
||||
|
||||
When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all non-canceled Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window, requests reminder-style toast behavior, and remains actionable for 5 minutes. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply.
|
||||
When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Classic calendar appointments through COM, filters all Teams meeting markers for the day into an in-memory cache, and schedules prompt checks from that cache. It does not query Outlook for every prompt. The notification asks `Record meeting?` with `Yes` and `No` actions when a cached Teams meeting reaches its start window, requests reminder-style toast behavior, and remains actionable for 5 minutes. Accepting starts a recording. If another recording is active, Meeting Assistant stops that recording through the normal stop path first, then starts the new recording, so the usual empty/too-short cleanup and summary handoff rules still apply.
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
@@ -317,7 +313,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. 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 waits for pending screenshot OCR work to complete or hit `Timeout` before the assistant context moves to `summarizing`.
|
||||
|
||||
| Setting | Purpose |
|
||||
| --- | --- |
|
||||
@@ -326,7 +322,7 @@ When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Cl
|
||||
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
|
||||
| `KeyEnv` | Environment variable name for the OCR API key. |
|
||||
| `Model` | Optional OCR model override. Blank falls back to `Agent:Model`. |
|
||||
| `Prompt` | OCR instruction prompt. The default asks for visible speakers/presenters, slide text as markdown, diagrams as Mermaid, scene descriptions, participant certainty, attendee metadata, and presentation/shared-screen crop coordinates when confidently isolatable. |
|
||||
| `Prompt` | OCR instruction prompt. The default asks for visible speakers/presenters, slide text as markdown, diagrams as Mermaid, scene descriptions, participant certainty, and presentation/shared-screen crop coordinates when confidently isolatable. |
|
||||
| `Timeout` | Maximum time to wait for OCR before summarization continues. |
|
||||
|
||||
## Agent And Summary
|
||||
@@ -339,7 +335,6 @@ When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Cl
|
||||
| `Key` | Optional inline API key. Prefer `KeyEnv`. |
|
||||
| `KeyEnv` | Environment variable name for the agent API key. |
|
||||
| `Model` | Model id sent to the endpoint. |
|
||||
| `UseStreaming` | Uses the Responses SSE transport when `true` (the default); uses the non-streaming Responses transport when `false`. |
|
||||
| `EnableThinking` | Enables reasoning options in requests when supported by the model/backend. |
|
||||
| `ReasoningEffort` | Reasoning effort: `None`, `Low`, `Medium`, `High`, or `ExtraHigh`. |
|
||||
| `ReconnectionAttempts` | Retry count for transient model endpoint failures. |
|
||||
@@ -353,22 +348,18 @@ When enabled on Windows, Meeting Assistant periodically syncs today's Outlook Cl
|
||||
|
||||
After transcription has fully finished, Meeting Assistant automatically runs the summary pipeline for the meeting. The summary agent writes the full markdown summary through `write_summary` and must provide a required `oneliner` value, which is stored in summary frontmatter and must not contain line breaks.
|
||||
|
||||
The built-in summary-agent instructions treat assistant context as persistent meeting-specific memory. When the agent encounters an unexpected problem, missing information, or an assumption while summarizing, it appends a concise note through `write_context` so later work on the same meeting can use that history. A configured `Agent:InitialPrompt` completely replaces the built-in instructions, so custom prompts must include equivalent guidance when this behavior is desired.
|
||||
|
||||
The summary agent can add and remove meeting-note attendees when transcript or OCR evidence is clear. It can override transcript speaker labels only when the evidence is very certain, and it can delete wrongfully matched identities. Final speaker identity learning and candidate updates run after the summary pipeline finishes so they use the summary-refined attendee list and any recorded speaker identity changes.
|
||||
|
||||
`ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, and `CompactionRemainingRatio` configure summary-agent context monitoring. Meeting Assistant estimates the outgoing Responses payload size, logs the estimated token count and remaining context, and compacts the conversation when only the configured remaining ratio is left.
|
||||
|
||||
## Workflow Rules Editor
|
||||
|
||||
`WorkflowRulesEditor` configures the tray-launched `Meeting Summary Agent` window. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, Responses transport, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
||||
`WorkflowRulesEditor` configures the tray-launched settings/logs assistant. Blank values inherit from `Agent`, so it uses the summarizer endpoint, key, model, reasoning, retry, output, and compaction settings unless explicitly overridden.
|
||||
|
||||
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `UseStreaming`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
||||
The overridable fields are `Endpoint`, `Key`, `KeyEnv`, `Model`, `EnableThinking`, `ReasoningEffort`, `ReconnectionAttempts`, `ReconnectionDelay`, `ContextWindowTokens`, `MaxOutputTokens`, `EnableCompaction`, `CompactionRemainingRatio`, `ResponsesCompactPath`, and `InitialPrompt`.
|
||||
|
||||
The assistant can edit workflow rules, manage speaker identities, read and replace the local appsettings file, read this configuration document, search and read copied OpenSpec specs, inspect application logs, create/search/read/write project files, and post-process past meeting artifacts by listing recent summaries and reading, writing, or searching summaries, transcripts, meeting notes, and assistant context files. For artifact metadata repairs, it has frontmatter-specific write tools that preserve the markdown body.
|
||||
|
||||
When asked to fix or investigate a meeting or summary, the agent is instructed to treat the matching assistant context as meeting-specific memory and read it for clues about problems, missing information, assumptions, prior fixes, and conclusions. After completing a repair, it appends a concise record of its fixes and conclusions to that context for future work on the meeting. This meeting-memory guidance is appended to the effective interactive-agent prompt even when `WorkflowRulesEditor:InitialPrompt` is configured.
|
||||
|
||||
It also has in-process diagnostic tools that mirror the local HTTP diagnostics for health, recording status, current Outlook meeting lookup, recent speaker identity merging, workflow configuration reload, and ASR transcribe/diarize checks.
|
||||
|
||||
The `search_spec` and `read_spec_file` tools are scoped to the copied `openspec/specs` markdown tree. Spec files are copied into build and publish output through an MSBuild glob, so newly added folders under `openspec/specs` are included automatically.
|
||||
@@ -383,7 +374,7 @@ Independently from stdout and stderr redirection, Meeting Assistant writes an ap
|
||||
%TEMP%\MeetingAssistant\Logs\meeting-assistant.log
|
||||
```
|
||||
|
||||
On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The tray-launched assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked.
|
||||
On startup it rotates the previous current file to `meeting-assistant.log.1` and keeps up to `.4`. The settings/logs assistant reads and searches these current and rotated files. If another app instance or test host still has the file open, rotation is skipped and logging appends to the current file so startup is not blocked.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ The rules file path is configured through `MeetingAssistant:Automation:RulesPath
|
||||
|
||||
If the configured path is empty, missing, or points to a blank file, the workflow engine does nothing.
|
||||
|
||||
The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` chat window for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
|
||||
The tray menu includes `Settings and logs`, which opens a small MewUI chat assistant for this configured rules file, the local speaker identity database, appsettings configuration, and application logs. The assistant uses the summarizer agent configuration by default and can be overridden through `MeetingAssistant:WorkflowRulesEditor`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -31,7 +31,6 @@ The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` cha
|
||||
"Endpoint": "",
|
||||
"KeyEnv": "",
|
||||
"Model": "",
|
||||
"UseStreaming": null,
|
||||
"EnableThinking": null,
|
||||
"ReasoningEffort": null,
|
||||
"MaxOutputTokens": null,
|
||||
@@ -46,8 +45,6 @@ The tray menu includes `Open agent`, which opens the `Meeting Summary Agent` cha
|
||||
|
||||
Blank editor values inherit from `MeetingAssistant:Agent`. This keeps the editor on the same LiteLLM Responses endpoint and model as the summarizer unless a value is explicitly configured. Each user-submitted editor turn sends its first model request with `X-Initiator: user`; follow-up model requests in the same turn, such as tool-call continuations, use `X-Initiator: agent`.
|
||||
|
||||
While the agent is working, the window shows visible reasoning summaries returned by the Responses endpoint and called tool names as lightweight activity lines above the bottom activity line. After the turn completes, those lines move into a collapsible `Worked for <duration>` expander between the user and assistant messages.
|
||||
|
||||
The editor agent prompt includes this document and is restricted to these tools:
|
||||
|
||||
- `read_rules`: read the configured rules file, optionally by inclusive 1-based line range.
|
||||
@@ -72,14 +69,6 @@ The engine runs when `MeetingRecordingCoordinator` emits a meeting workflow even
|
||||
- `state_transition`: after the assistant context state moves forward.
|
||||
- `speaker_identified`: when live or final speaker identification reports a display name.
|
||||
- `transcript_line`: after a formatted live transcript line is durably appended, before any changed line is rewritten in place, and before lines are used in full transcript rewrites.
|
||||
- `attendee_added`: before a newly added attendee is stored in the meeting note.
|
||||
|
||||
`attendee_added` is emitted only by Meeting Assistant code paths that intentionally add an attendee
|
||||
through the workflow engine, notification actions, OCR/screenshot processing, summarizer tools, or
|
||||
other attendee-add operations. Direct edits to a meeting note file, including direct artifact/frontmatter
|
||||
repair writes by an agent, are left verbatim and do not trigger attendee automation.
|
||||
|
||||
When a recording is started by accepting a calendar notification, the cached appointment metadata is applied before the `created` event. Rules still receive the normal `created` event and the normal `collecting metadata` to `transcribing` state transition, but they see the accepted appointment title, attendees, agenda, and scheduled end instead of a generated placeholder or a separate Outlook current-meeting lookup result.
|
||||
|
||||
For every event, the engine:
|
||||
|
||||
@@ -169,22 +158,6 @@ on:
|
||||
speaker: Guest-1
|
||||
```
|
||||
|
||||
### `attendee_added`
|
||||
|
||||
Runs before a newly added attendee is stored. `equals`, `contains`, and `regex` filters are optional; omitted filters match any attendee. `equals` and `contains` are case-insensitive.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
- attendee_added:
|
||||
contains: Contoso
|
||||
```
|
||||
|
||||
```yaml
|
||||
on:
|
||||
- attendee_added:
|
||||
regex: '@contoso\.com>$'
|
||||
```
|
||||
|
||||
## Conditions
|
||||
|
||||
Conditions use NCalc expressions. Dotted workflow variables may be written directly; the engine rewrites them into NCalc parameters internally.
|
||||
@@ -207,7 +180,6 @@ Available condition variables:
|
||||
- `speaker.name`
|
||||
- `transcript.line`
|
||||
- `transcript.speaker`
|
||||
- `attendee.name`
|
||||
|
||||
Available helper functions:
|
||||
|
||||
@@ -241,7 +213,6 @@ Step `value` fields can be plain strings or Razor templates. Razor templates rec
|
||||
- `Model.Speaker.Name`
|
||||
- `Model.Transcript.Line`
|
||||
- `Model.Transcript.Speaker`
|
||||
- `Model.Attendee.Name`
|
||||
|
||||
Example:
|
||||
|
||||
@@ -284,11 +255,7 @@ steps:
|
||||
|
||||
### `set_property`
|
||||
|
||||
Sets a supported property. Supported meeting properties are `title` and `meeting.title`. During `transcript_line` events, `transcript.line` is also supported. During `attendee_added` events, `attendee.name` is supported. For live transcription, changing `transcript.line` rewrites the referenced formatted line after the workflow task completes. For attendee additions, changing `attendee.name` stores the transformed attendee instead of the original added value.
|
||||
|
||||
Rules triggered by `attendee_added` are transformation-only: their steps must use `set_property`
|
||||
with `property: attendee.name`. They can still use trigger filters, conditions, and Razor templates
|
||||
to decide and compute the transformed value, but they cannot perform note/context side effects.
|
||||
Sets a supported property. Supported meeting properties are `title` and `meeting.title`. During `transcript_line` events, `transcript.line` is also supported. For live transcription, changing `transcript.line` rewrites the referenced formatted line after the workflow task completes.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
@@ -304,13 +271,6 @@ steps:
|
||||
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
|
||||
```
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: attendee.name
|
||||
value: '@Model.Attendee.Name.Replace(" (Contoso)", "")'
|
||||
```
|
||||
|
||||
### `add_context`
|
||||
|
||||
Appends rendered text to the assistant context artifact body. This step does not count as a meeting-note mutation and does not cause a meeting-note save by itself.
|
||||
@@ -406,20 +366,6 @@ rules:
|
||||
value: '@Model.Transcript.Line.Replace("*****", "[redacted]")'
|
||||
```
|
||||
|
||||
Clean attendee names as they are added:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- name: clean-contoso-attendees
|
||||
on:
|
||||
- attendee_added:
|
||||
contains: Contoso
|
||||
steps:
|
||||
- uses: set_property
|
||||
property: attendee.name
|
||||
value: '@Model.Attendee.Name.Replace(" (Contoso)", "")'
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Core files:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
# Proposal
|
||||
|
||||
Meeting Assistant currently records the Windows default microphone endpoint and offers no in-app way to choose a different input. Users with multiple microphones need a stable configuration default and a quick tray-menu override without changing Windows settings.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add an optional `Recording:MicrophoneDeviceId` setting.
|
||||
- Keep using the Windows default capture endpoint when the setting is blank or absent.
|
||||
- Enumerate active microphone capture endpoints for the tray icon menu.
|
||||
- Add a `Microphone` submenu to the tray icon menu with one checked item for the effective microphone.
|
||||
- Let selecting a microphone from the tray menu change the microphone used for the next capture start.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affects Windows audio capture setup and tray menu rendering.
|
||||
- Does not change system loopback capture or transcription provider behavior.
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Recording mode captures microphone and computer output
|
||||
Meeting Assistant SHALL capture microphone input and computer output and combine them into one audio stream for transcription.
|
||||
|
||||
Meeting Assistant SHALL capture audio as 16 kHz mono PCM chunks for the existing recording and transcription pipeline.
|
||||
|
||||
Meeting Assistant SHALL capture microphone and system loopback as separate input streams before producing the final mono chunks.
|
||||
|
||||
Meeting Assistant SHALL clean the microphone stream with a local acoustic echo cancellation stage that uses system loopback as the far-end reference.
|
||||
|
||||
Meeting Assistant SHALL produce final mono chunks by adding the cleaned microphone samples and system samples.
|
||||
|
||||
Meeting Assistant SHALL align microphone and system samples through per-source buffers before mixing and SHALL NOT emit normal live audio chunks that contain only one source while the other source is merely delayed.
|
||||
|
||||
When one source stays quiet beyond the alignment timeout, Meeting Assistant SHALL mix the available source with synthetic silence for the missing source instead of blocking transcription.
|
||||
|
||||
Meeting Assistant SHALL allow the final microphone/system mono mix to apply configurable microphone and system gain before combining samples.
|
||||
|
||||
Meeting Assistant SHALL use the active run or launch profile recording options when configuring capture format and final microphone/system gains.
|
||||
|
||||
Meeting Assistant SHALL clamp mixed samples after gain is applied.
|
||||
|
||||
Meeting Assistant SHALL write only the mixed stream to the temporary WAV used by transcription and finalization.
|
||||
|
||||
Meeting Assistant SHALL allow `Recording:MicrophoneDeviceId` to select a Windows microphone capture endpoint.
|
||||
|
||||
When `Recording:MicrophoneDeviceId` is blank or absent, Meeting Assistant SHALL use the Windows default capture endpoint.
|
||||
|
||||
When a microphone is selected from the tray icon menu, Meeting Assistant SHALL use that selected microphone for later recording starts until another microphone is selected or the process exits.
|
||||
|
||||
The tray icon right-click menu SHALL expose a `Microphone` submenu listing active microphone capture endpoints.
|
||||
|
||||
The `Microphone` submenu SHALL mark exactly one effective microphone as checked.
|
||||
|
||||
When no runtime microphone override is selected, the checked microphone SHALL be the configured microphone when it is available, otherwise the Windows default capture endpoint.
|
||||
|
||||
#### Scenario: Both sources produce audio
|
||||
- **WHEN** microphone and computer output audio chunks are available
|
||||
- **THEN** Meeting Assistant mixes them into one PCM stream before transcription
|
||||
|
||||
#### Scenario: Mixed audio uses cleaned microphone and system audio
|
||||
- **GIVEN** the echo canceller cleans a microphone chunk to sample `2000`
|
||||
- **AND** the matching system chunk has sample `10000`
|
||||
- **WHEN** microphone and system chunks are mixed with gains `1` and `1`
|
||||
- **THEN** the mixed sample is `12000`
|
||||
|
||||
#### Scenario: Temporary recording stores only the mixed stream
|
||||
- **GIVEN** Meeting Assistant has mixed microphone and system audio into one PCM chunk
|
||||
- **WHEN** Meeting Assistant appends the chunk to the temporary recording
|
||||
- **THEN** the main temporary WAV contains the mixed PCM
|
||||
- **AND** no microphone or system sidecar WAV is written
|
||||
|
||||
#### Scenario: Launch profile recording options configure capture and gains
|
||||
- **GIVEN** an active launch profile configures sample format and microphone/system mix gains
|
||||
- **WHEN** Meeting Assistant captures and mixes audio for that run
|
||||
- **THEN** the microphone and system capture sources receive that launch profile recording configuration
|
||||
- **AND** the mixed output uses that launch profile's microphone/system gains
|
||||
|
||||
#### Scenario: Delayed sources are buffered before mixing
|
||||
- **GIVEN** microphone audio arrives before matching system audio
|
||||
- **WHEN** matching system audio arrives after a short delay
|
||||
- **THEN** Meeting Assistant emits one mixed chunk for the aligned samples
|
||||
- **AND** it does not emit separate microphone-only and system-only chunks for that delayed pair
|
||||
|
||||
#### Scenario: Quiet system audio does not block microphone transcription
|
||||
- **GIVEN** microphone audio arrives
|
||||
- **AND** system loopback audio does not arrive within the alignment timeout
|
||||
- **WHEN** Meeting Assistant mixes the available audio
|
||||
- **THEN** it emits the microphone audio mixed with silent system audio
|
||||
- **AND** live transcription can continue while system loopback is quiet
|
||||
|
||||
#### Scenario: Continuous microphone audio does not suppress the alignment timeout
|
||||
- **GIVEN** microphone audio keeps arriving
|
||||
- **AND** system loopback audio stays unavailable past the alignment timeout
|
||||
- **WHEN** Meeting Assistant checks the buffered microphone audio
|
||||
- **THEN** it emits the buffered microphone audio mixed with silent system audio
|
||||
- **AND** it does not wait indefinitely for a loopback chunk
|
||||
|
||||
#### Scenario: Device-level capture cannot be verified in tests
|
||||
- **WHEN** automated tests run without live audio devices
|
||||
- **THEN** Meeting Assistant verifies the audio mixer through deterministic source abstractions rather than depending on physical microphone or speaker devices
|
||||
|
||||
#### Scenario: Configured microphone is used for capture
|
||||
- **GIVEN** `Recording:MicrophoneDeviceId` identifies an active microphone endpoint
|
||||
- **WHEN** Meeting Assistant starts microphone capture
|
||||
- **THEN** it captures from that endpoint
|
||||
|
||||
#### Scenario: Blank microphone setting uses Windows default
|
||||
- **GIVEN** `Recording:MicrophoneDeviceId` is blank
|
||||
- **WHEN** Meeting Assistant starts microphone capture
|
||||
- **THEN** it captures from the Windows default capture endpoint
|
||||
|
||||
#### Scenario: Tray menu lists microphones with current selection checked
|
||||
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
|
||||
- **AND** `integrated microphone` is the effective microphone
|
||||
- **WHEN** the taskbar menu is opened
|
||||
- **THEN** it shows a `Microphone` submenu
|
||||
- **AND** the `integrated microphone` item is checked
|
||||
- **AND** the `other microphone` item is unchecked
|
||||
|
||||
#### Scenario: Tray microphone selection changes later capture
|
||||
- **GIVEN** active microphone endpoints `integrated microphone` and `other microphone`
|
||||
- **WHEN** the user selects `other microphone` from the taskbar microphone submenu
|
||||
- **THEN** later recording starts capture from `other microphone`
|
||||
@@ -1,9 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add requirement scenarios for configured microphone selection and tray runtime selection.
|
||||
- [x] Add a failing behavior test for the tray microphone submenu.
|
||||
- [x] Add a failing behavior test for configured/default microphone resolution.
|
||||
- [x] Implement microphone device enumeration, selection state, and capture endpoint resolution.
|
||||
- [x] Render the microphone submenu and execute microphone selection actions.
|
||||
- [x] Update configuration documentation.
|
||||
- [x] Run focused tests and `openspec validate add-runtime-microphone-selection --strict`.
|
||||
@@ -1,12 +0,0 @@
|
||||
## Why
|
||||
Screenshot OCR can identify visible meeting participants before the summary agent runs. When the model provides attendee names directly, Meeting Assistant should use that evidence immediately instead of waiting for later summary refinement.
|
||||
|
||||
## What Changes
|
||||
- Extend screenshot OCR metadata so the model can return a partial attendee list.
|
||||
- Merge OCR-provided attendees into the meeting note without duplicates.
|
||||
- Canonicalize attendee additions through speaker identity aliases before saving.
|
||||
|
||||
## Impact
|
||||
- Updates screenshot OCR prompt/default metadata shape.
|
||||
- Updates OCR result parsing and screenshot OCR completion handling.
|
||||
- Adds behavior tests for parsing attendees and updating meeting notes.
|
||||
-71
@@ -1,71 +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.
|
||||
|
||||
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.
|
||||
|
||||
When screenshot OCR metadata includes attendee names visible or otherwise deduced from the screenshot, Meeting Assistant SHALL add those attendees to the meeting note.
|
||||
|
||||
Screenshot OCR attendee additions SHALL preserve existing attendees, SHALL NOT create duplicate attendees, and SHALL canonicalize names through speaker identity aliases before saving.
|
||||
|
||||
Screenshot OCR attendee metadata MAY be partial and SHALL NOT remove existing attendees that are absent from the screenshot result.
|
||||
|
||||
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, return any visible or deduced attendees as metadata, 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: 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
|
||||
|
||||
#### Scenario: OCR attendees are added to the meeting note
|
||||
- **GIVEN** screenshot OCR is configured
|
||||
- **AND** the current meeting note already has attendees
|
||||
- **WHEN** screenshot OCR metadata returns attendee names from the screenshot
|
||||
- **THEN** Meeting Assistant adds those attendees to the meeting note
|
||||
- **AND** keeps existing attendees
|
||||
- **AND** does not duplicate attendees that match existing names or speaker identity aliases
|
||||
|
||||
#### Scenario: OCR partial attendees do not remove existing attendees
|
||||
- **GIVEN** the current meeting note has attendees `Ada` and `Grace`
|
||||
- **WHEN** screenshot OCR metadata returns only `Ada`
|
||||
- **THEN** Meeting Assistant keeps `Grace` in the meeting note
|
||||
|
||||
#### 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,5 +0,0 @@
|
||||
- [x] 1. Add OpenSpec scenario for screenshot OCR attendee additions.
|
||||
- [x] 2. Add failing behavior tests for OCR attendee parsing and meeting-note updates.
|
||||
- [x] 3. Parse `attendees` from screenshot OCR metadata.
|
||||
- [x] 4. Merge OCR attendees into the meeting note through alias-aware canonicalization.
|
||||
- [x] 5. Run focused tests and `openspec validate add-screenshot-ocr-attendees --strict`.
|
||||
@@ -1,12 +0,0 @@
|
||||
## Why
|
||||
Screenshot OCR can fail after a useful screenshot has already been saved to the assistant context. The user should be able to retry OCR for that exact screenshot from the note, matching the existing summary retry workflow.
|
||||
|
||||
## What Changes
|
||||
- Add a retry link when screenshot OCR fails or times out.
|
||||
- Add a local retry endpoint for screenshot OCR.
|
||||
- Rerun OCR for the exact saved screenshot and replace the same assistant-context OCR block.
|
||||
|
||||
## Impact
|
||||
- Updates screenshot OCR failure handling.
|
||||
- Adds local API surface for screenshot OCR retry.
|
||||
- Adds behavior tests for retry link generation and retry execution.
|
||||
-65
@@ -1,65 +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.
|
||||
|
||||
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.
|
||||
|
||||
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: 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: 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,5 +0,0 @@
|
||||
- [x] 1. Add OpenSpec scenario for screenshot OCR retry.
|
||||
- [x] 2. Add failing behavior tests for OCR failure retry links and retry execution.
|
||||
- [x] 3. Preserve retryable OCR blocks on failure/timeout and include retry links.
|
||||
- [x] 4. Add local retry endpoint for screenshot OCR.
|
||||
- [x] 5. Run focused tests, full tests, and `openspec validate add-screenshot-ocr-retry --strict`.
|
||||
@@ -1,8 +0,0 @@
|
||||
## Why
|
||||
The transcript marker for switching transcription profiles currently tells the summarizer that the profile changed, but not that speaker recognition state was reset. That can make later transcript interpretation less clear.
|
||||
|
||||
## What Changes
|
||||
- Extend the profile-switch transcript marker to state that speaker recognition and identities were reset.
|
||||
|
||||
## Impact
|
||||
- Updates the active profile-switch recording behavior and its test coverage.
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Active recording can switch launch profiles
|
||||
Meeting Assistant SHALL allow a launch profile hotkey or named-profile recording toggle request to switch the active meeting to that profile while recording capture is active.
|
||||
|
||||
When switching profiles during an active meeting, Meeting Assistant SHALL keep the existing meeting note, transcript, assistant context, summary path, user notes, calendar metadata, projects, attendees, and assistant-context state.
|
||||
|
||||
When switching profiles during an active meeting, Meeting Assistant SHALL NOT create new meeting artifacts, SHALL NOT collect calendar metadata again, and SHALL NOT run the summary pipeline for the old profile segment.
|
||||
|
||||
When switching profiles during an active meeting, Meeting Assistant SHALL stop and drain the current speech recognition pipeline, append a transcript marker indicating the target profile and that speaker recognition and identities were reset, start a new speech recognition pipeline using the target profile's resolved options, and continue writing live transcription to the same transcript file.
|
||||
|
||||
When audio is captured while the old speech recognition pipeline is draining, Meeting Assistant SHALL buffer that audio and send it to the new profile's speech recognition pipeline after it is ready.
|
||||
|
||||
When switching profiles during an active meeting, Meeting Assistant SHALL clear run-local speaker label mappings for future transcript segments because backend speaker IDs can change across speech recognition processes. Already-written named transcript segments SHALL remain unchanged.
|
||||
|
||||
#### Scenario: Active meeting switches to named profile
|
||||
- **GIVEN** a meeting was started with the default launch profile
|
||||
- **WHEN** the user triggers launch profile `english` while recording is active
|
||||
- **THEN** Meeting Assistant keeps the existing meeting artifacts
|
||||
- **AND** drains the old speech recognition pipeline without running summary generation
|
||||
- **AND** appends a transcript marker for the profile switch
|
||||
- **AND** the transcript marker states that speaker recognition and identities were reset
|
||||
- **AND** starts transcription with the resolved `english` profile
|
||||
- **AND** continues writing transcript segments to the same transcript file
|
||||
|
||||
#### Scenario: Captured audio is buffered while switching
|
||||
- **GIVEN** a meeting is switching from one launch profile to another
|
||||
- **WHEN** audio chunks are captured before the new speech recognition pipeline is ready
|
||||
- **THEN** Meeting Assistant buffers those chunks
|
||||
- **AND** sends the buffered chunks to the new profile's speech recognition pipeline after it starts
|
||||
|
||||
#### Scenario: Switching clears future speaker mappings only
|
||||
- **GIVEN** a live speaker label was mapped to a named speaker before switching profiles
|
||||
- **WHEN** Meeting Assistant switches to another launch profile
|
||||
- **THEN** already-written transcript text keeps the named speaker
|
||||
- **AND** later transcript segments from the new pipeline do not reuse the old backend speaker label mapping
|
||||
@@ -1,4 +0,0 @@
|
||||
- [x] 1. Add OpenSpec scenario for the clarified profile-switch marker.
|
||||
- [x] 2. Add failing behavior coverage for the marker text.
|
||||
- [x] 3. Update the profile-switch marker.
|
||||
- [x] 4. Run focused tests and `openspec validate clarify-profile-switch-marker --strict`.
|
||||
@@ -1,12 +0,0 @@
|
||||
## Why
|
||||
Users sometimes add screenshots or image references directly to the meeting note while a meeting is in progress. The summary agent should see OCR for those manually added images even when they were not captured through the Meeting Assistant screenshot hotkey.
|
||||
|
||||
## What Changes
|
||||
- Scan the meeting note after transcription and before summarization for Obsidian and Markdown image embeds.
|
||||
- Add matching image references and OCR output to the assistant context without modifying the meeting note or copying image files.
|
||||
- Wait for this OCR pass before moving the assistant context into summarization.
|
||||
|
||||
## Impact
|
||||
- Adds a pre-summary screenshot OCR pass over user-authored meeting note image embeds.
|
||||
- Reuses the existing screenshot OCR client and pending OCR wait path.
|
||||
- Does not perform crop extraction or attendee updates for meeting-note images.
|
||||
-70
@@ -1,70 +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.
|
||||
|
||||
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 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: 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 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,5 +0,0 @@
|
||||
- [x] 1. Add OpenSpec scenario for meeting-note image embed OCR before summarization.
|
||||
- [x] 2. Add failing behavior tests for meeting-note image OCR and pre-summary waiting.
|
||||
- [x] 3. Implement image embed discovery and assistant-context OCR entries without modifying the meeting note.
|
||||
- [x] 4. Hook the scan before summary state transition and wait for completion.
|
||||
- [x] 5. Run focused tests, full tests, and `openspec validate ocr-meeting-note-image-embeds --strict`.
|
||||
@@ -1,14 +0,0 @@
|
||||
## Why
|
||||
When several Teams meetings are plausible in Outlook, a standalone "current meeting" lookup can pick the wrong appointment or no appointment. The recording-start notification already represents one specific cached calendar meeting, so accepting that notification should start the recording with that meeting's metadata.
|
||||
|
||||
## What Changes
|
||||
- Carry meeting title, attendees, agenda, and scheduled end in cached calendar prompt candidates.
|
||||
- Start recordings accepted from a meeting-start notification with the accepted candidate's metadata instead of running a separate Outlook current-meeting lookup.
|
||||
- Preserve the normal workflow lifecycle events and rules for prompted starts.
|
||||
- Exclude canceled Outlook appointments from prompt candidates and standalone current-meeting metadata lookup.
|
||||
|
||||
## Impact
|
||||
- Updates the calendar prompt scheduler and recording controller seam.
|
||||
- Extends Windows Outlook calendar prompt extraction to include metadata already used by meeting notes.
|
||||
- Filters canceled Outlook appointments before prompting or selecting metadata.
|
||||
- Adds tests for prompt-supplied metadata and workflow rule execution.
|
||||
-120
@@ -1,120 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### 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.
|
||||
|
||||
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, or ignores 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.
|
||||
|
||||
#### 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: 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: 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
|
||||
|
||||
### Requirement: Windows Outlook enrichment is optional
|
||||
Meeting Assistant SHALL gate Outlook Classic COM enrichment behind the Windows compilation target.
|
||||
|
||||
When the Windows build starts a meeting and Outlook Classic has exactly one current Teams appointment, 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.
|
||||
|
||||
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 exposes exactly one current Teams appointment
|
||||
- **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: Canceled appointment is ignored during metadata lookup
|
||||
- **GIVEN** Outlook Classic exposes one canceled current Teams appointment
|
||||
- **AND** Outlook Classic exposes one active current 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 current 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 ambiguous
|
||||
- **WHEN** Outlook Classic is unavailable or more than one current Teams appointment is found
|
||||
- **THEN** Meeting Assistant starts the recording with the default generated meeting title and empty agenda
|
||||
- **AND** omits `scheduled_end` from assistant-context frontmatter
|
||||
@@ -1,6 +0,0 @@
|
||||
- [x] 1. Add OpenSpec scenario for prompt-accepted metadata.
|
||||
- [x] 2. Add failing behavior coverage for prompted metadata starts.
|
||||
- [x] 3. Pass prompted metadata through the scheduler/controller/coordinator start path.
|
||||
- [x] 4. Populate cached Outlook prompt candidates with metadata.
|
||||
- [x] 5. Exclude canceled Outlook appointments from recording prompts and metadata lookup.
|
||||
- [x] 6. Run focused tests, full tests, and `openspec validate use-prompted-calendar-metadata --strict`.
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-01
|
||||
@@ -1,30 +0,0 @@
|
||||
# Design
|
||||
|
||||
## Event Model
|
||||
|
||||
Add a workflow event type, `attendee_added`, that carries the attendee string being added. The event is used as a transformation hook before a caller stores the attendee in the meeting note. Rules can match the event with `equals`, `contains`, or `regex` trigger filters over the current attendee value.
|
||||
|
||||
## Transformation Contract
|
||||
|
||||
The workflow engine exposes `TransformAttendeeAsync`, mirroring `TransformTranscriptLineAsync`. During `attendee_added` events:
|
||||
|
||||
- conditions can read `attendee.name`,
|
||||
- Razor templates can read `Model.Attendee.Name`,
|
||||
- `set_property attendee.name` mutates the attendee value returned to the caller.
|
||||
|
||||
Other workflow steps keep their existing semantics, but the intended transformation path is `set_property attendee.name`.
|
||||
Rules triggered by `attendee_added` are limited to that transformation path so the value transform does not hide unrelated note or context side effects.
|
||||
|
||||
Direct meeting note file edits remain outside the workflow event model. If a user or agent writes a meeting note file/frontmatter directly, Meeting Assistant preserves that content verbatim and does not emit `attendee_added`.
|
||||
|
||||
## Call Sites
|
||||
|
||||
Callers transform attendee candidates before writing them:
|
||||
|
||||
- metadata and accepted prompt metadata after canonicalization,
|
||||
- workflow `add_attendee` steps before de-duplication,
|
||||
- screenshot OCR candidates before canonicalization and merge,
|
||||
- summary-agent `add_attendee` tool calls before duplicate checks and note writes,
|
||||
- speaker identity attendee additions before adding matched names.
|
||||
|
||||
Unchanged metadata attendees preserve the existing canonicalizer output, including email display strings.
|
||||
@@ -1,17 +0,0 @@
|
||||
## Why
|
||||
|
||||
Attendees enter Meeting Assistant from several paths: calendar metadata, accepted recording prompts, workflow actions, screenshot OCR, speaker identity updates, and summarizer add-attendee tools. Today those paths can normalize display names, but they cannot apply local transformation rules such as trimming company suffixes, replacing aliases, or cleaning attendee strings consistently.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add an `attendee_added` workflow trigger that runs whenever Meeting Assistant adds an attendee to a meeting note.
|
||||
- Allow `attendee_added` triggers to filter the added attendee with full equality, substring, or regex matching.
|
||||
- Expose `attendee.name` to workflow conditions and Razor templates.
|
||||
- Allow `set_property` to update `attendee.name` during `attendee_added` events, mirroring transcript-line transformation.
|
||||
- Apply attendee transformations to attendees added by metadata import, prompted-start metadata, workflow `add_attendee`, screenshot OCR, and summary/workflow editor agent tools.
|
||||
|
||||
## Impact
|
||||
|
||||
- Workflow engine, rules schema validation, and workflow documentation.
|
||||
- Attendee write paths in recording metadata, screenshot OCR, summary tools, and workflow actions.
|
||||
- Behavior tests for workflow transformation and representative attendee-add sources.
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Meeting automation rules support lifecycle triggers
|
||||
Meeting Assistant SHALL support rule triggers for `created`, `state_transition`, `speaker_identified`, `transcript_line`, and `attendee_added`.
|
||||
|
||||
An `attendee_added` trigger MAY filter by `equals`, `contains`, or `regex` against the attendee value being added. `equals` and `contains` filters SHALL match case-insensitively.
|
||||
|
||||
Meeting Assistant SHALL apply attendee-added transformations before storing attendees added from meeting metadata, accepted recording-prompt metadata, workflow `add_attendee` steps, screenshot OCR, speaker identity updates, and summarizer add-attendee tools.
|
||||
|
||||
#### Scenario: Attendee added rule filters and transforms a metadata attendee
|
||||
- **GIVEN** a configured rule that triggers on added attendees containing `@contoso.com`
|
||||
- **AND** the rule sets `attendee.name` to a display name derived from the added attendee
|
||||
- **WHEN** Meeting Assistant adds attendee `Ada Lovelace <ada@contoso.com>` from meeting metadata
|
||||
- **THEN** the stored meeting attendee is the transformed attendee name
|
||||
- **AND** the original attendee string is not stored
|
||||
|
||||
### Requirement: Meeting automation rules support conditions and steps
|
||||
Meeting Assistant SHALL expose the added attendee name to `attendee_added` rule conditions and Razor step templates as `attendee.name`.
|
||||
|
||||
The `set_property` step SHALL support setting `attendee.name` during `attendee_added` events.
|
||||
|
||||
Rules with an `attendee_added` trigger SHALL be limited to transforming `attendee.name` with `set_property`.
|
||||
|
||||
Direct meeting note file writes SHALL NOT emit `attendee_added` or transform attendee values.
|
||||
|
||||
#### Scenario: Attendee added rules can use regex and Razor
|
||||
- **GIVEN** a configured `attendee_added` rule with a regex filter over the added attendee
|
||||
- **WHEN** Meeting Assistant adds an attendee matching the regex
|
||||
- **THEN** the rule can set `attendee.name` with a Razor template using `Model.Attendee.Name`
|
||||
@@ -1,8 +0,0 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Add OpenSpec scenarios for attendee-added trigger filtering and transformation.
|
||||
- [x] Add a failing workflow-engine behavior test for transforming an added attendee through `attendee_added`.
|
||||
- [x] Implement attendee-added workflow event, trigger filters, `attendee.name` condition/template value, and `set_property attendee.name`.
|
||||
- [x] Apply attendee transformations from metadata/prompted starts, workflow `add_attendee`, screenshot OCR, and agent attendee tools.
|
||||
- [x] Update workflow engine documentation.
|
||||
- [x] Run focused tests plus `openspec validate transform-added-attendees --strict`.
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-17
|
||||
@@ -1,45 +0,0 @@
|
||||
## Context
|
||||
|
||||
Meeting Assistant already creates one assistant context artifact per meeting and gives both the automatic summary agent and the interactive agent window tools that can read and append to it. The current summary prompt calls the file a notebook, while the interactive agent prompt describes only generic artifact repair. Neither prompt establishes assistant context as the durable handoff point for uncertainty, failures, assumptions, repairs, and conclusions about one meeting.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Make the assistant context's meeting-specific memory role explicit to both agents.
|
||||
- Preserve summarization problems, missing information, and assumptions for later investigation.
|
||||
- Make the interactive agent consult that memory before repairing a meeting or summary.
|
||||
- Make the interactive agent record completed fixes and conclusions for later agents.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Add a new artifact type, schema, section format, or database.
|
||||
- Automatically rewrite or summarize existing assistant context files.
|
||||
- Change tool permissions or allow agents to access artifacts outside existing configured scopes.
|
||||
- Override an explicitly configured custom summary-agent prompt with built-in summary guidance.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Extend the existing instruction builders
|
||||
|
||||
The automatic summary behavior will be added to `MeetingSummaryInstructionBuilder.DefaultInitialPrompt`. This preserves the existing contract that a configured `Agent:InitialPrompt` replaces the built-in summary prompt.
|
||||
|
||||
The interactive behavior will be included in the instruction builder's always-appended meeting-artifact guidance. This keeps the meeting-memory reminder available even when the interactive agent has a custom initial prompt, alongside the tool capabilities that Meeting Assistant already appends.
|
||||
|
||||
Alternative considered: implement automatic interception or mandatory writes whenever an agent encounters uncertainty. The application cannot reliably infer those semantic events from arbitrary model turns, so explicit instructions are the smallest dependable mechanism.
|
||||
|
||||
### Keep memory entries append-oriented and concise
|
||||
|
||||
Agents will be told to append problems, missing information, assumptions, fixes, and conclusions to the matching assistant context. Existing `write_context` behavior already appends by default, which preserves earlier observations and avoids introducing a structured migration.
|
||||
|
||||
Alternative considered: define mandatory headings or a machine-readable memory schema. That would add formatting and compatibility obligations without being necessary for the requested agent handoff behavior.
|
||||
|
||||
### Keep user-facing summary content separate
|
||||
|
||||
Assistant context remains internal meeting memory; the generated summary remains the user-facing artifact. Assumptions and missing evidence can inform explicit uncertainty in the summary, but diagnostic notes and repair history belong in assistant context.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Repeated agents may append duplicate observations] → Instruct agents to read existing context first and keep entries concise.
|
||||
- [Assistant context can grow over time] → Preserve existing ranged and tail reads; no new retention mechanism is introduced.
|
||||
- [Configured custom summary prompts omit the new built-in guidance] → Preserve the documented replacement semantics and leave responsibility with the custom prompt author.
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
## Why
|
||||
|
||||
The automatic summarizer and interactive agent window can encounter uncertainty, missing information, and repair conclusions that are useful to later work on the same meeting. Those observations currently depend on the active agent turn instead of being consistently preserved in the meeting's assistant context.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Treat each assistant context file as meeting-specific memory shared by agents working on that meeting.
|
||||
- Guide the automatic summarizer to record unexpected problems, missing information, and assumptions in assistant context.
|
||||
- Guide the interactive agent window to consult assistant context when repairing a meeting or summary.
|
||||
- Guide the interactive agent window to append its fixes and conclusions to assistant context for future work on that meeting.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-summary`: Define the automatic summarizer's responsibility to persist uncertainty and problems in meeting-specific assistant context.
|
||||
- `meeting-session`: Define how the interactive agent window uses and updates assistant context during meeting and summary repairs.
|
||||
|
||||
## Impact
|
||||
|
||||
- Summary-agent default instructions and their behavior tests.
|
||||
- Interactive agent-window instructions and their behavior tests.
|
||||
- Agent documentation describing assistant context usage.
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Interactive agent uses assistant context as meeting memory
|
||||
The interactive agent window instructions SHALL identify each assistant context file as meeting-specific memory.
|
||||
|
||||
When the user asks the interactive agent to fix or investigate a meeting or summary, the instructions SHALL direct the agent to read the matching assistant context for clues about problems, missing information, assumptions, prior fixes, and conclusions.
|
||||
|
||||
After repairing a meeting or summary, the instructions SHALL direct the agent to append a concise record of its fixes and conclusions to the matching assistant context.
|
||||
|
||||
#### Scenario: Interactive agent repairs a meeting artifact
|
||||
- **GIVEN** a meeting has an assistant context file
|
||||
- **WHEN** the user asks the interactive agent to fix that meeting or its summary
|
||||
- **THEN** the agent instructions direct it to inspect the matching assistant context for relevant meeting-specific memory
|
||||
- **AND** direct it to append its fixes and conclusions to that assistant context
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Summary agent preserves meeting-specific working memory
|
||||
When Meeting Assistant uses the built-in summary-agent instructions, those instructions SHALL identify assistant context as persistent meeting-specific memory.
|
||||
|
||||
The built-in instructions SHALL direct the summary agent to append unexpected problems, missing information, and assumptions encountered while summarizing to assistant context.
|
||||
|
||||
#### Scenario: Summarizer encounters uncertainty
|
||||
- **GIVEN** the built-in summary-agent instructions are in use
|
||||
- **WHEN** the summary agent encounters an unexpected problem, cannot find needed information, or must make an assumption
|
||||
- **THEN** its instructions direct it to append a concise record to the meeting's assistant context
|
||||
- **AND** later agents can discover that record when working on the same meeting
|
||||
@@ -1,14 +0,0 @@
|
||||
## 1. Automatic Summary Agent
|
||||
|
||||
- [x] 1.1 Add a behavior test that requires the built-in summary instructions to treat assistant context as meeting-specific memory and record problems, missing information, and assumptions.
|
||||
- [x] 1.2 Update the built-in summary-agent guidance to satisfy the memory behavior.
|
||||
|
||||
## 2. Interactive Agent Window
|
||||
|
||||
- [x] 2.1 Add a behavior test that requires the interactive instructions to inspect assistant context during meeting or summary repair and record fixes and conclusions.
|
||||
- [x] 2.2 Update the interactive agent-window guidance to satisfy the memory behavior.
|
||||
|
||||
## 3. Documentation and Validation
|
||||
|
||||
- [x] 3.1 Document assistant context as shared meeting-specific agent memory.
|
||||
- [x] 3.2 Run focused behavior tests, the full test suite, and strict OpenSpec validation.
|
||||
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-27
|
||||
@@ -1,40 +0,0 @@
|
||||
## Context
|
||||
|
||||
`LiteLlmResponsesChatClient` exposes a non-streaming `IChatClient` interface and currently buffers every successful `/v1/responses` body before parsing it as one JSON document. The deployed LiteLLM `chatgpt/gpt-5.5` route instead returns Responses Server-Sent Events. Microsoft.Extensions.AI.OpenAI already provides a Responses streaming adapter that maps the OpenAI SDK's typed SSE updates into `ChatResponseUpdate` values, including text, reasoning, function calls, response metadata, and usage.
|
||||
|
||||
That adapter uses `FunctionCallContent.CreateFromParsedArguments`, which records argument-mapping failures on the function call instead of throwing them from the response parser. The default function invoker does not itself prevent invocation when that property is populated, so Meeting Assistant must turn the recorded parse failure into a matching function result before calling the tool.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Preserve the existing non-streaming `IChatClient` contract while selecting the supported Responses streaming or non-streaming path through configuration.
|
||||
- Delegate SSE framing, event deserialization, streamed function-call assembly, response metadata, and usage mapping to the OpenAI SDK and Microsoft.Extensions.AI.OpenAI adapter.
|
||||
- Preserve the original encoded function arguments when forwarding conversation history.
|
||||
- Return a safe, structured invalid-arguments result for malformed argument JSON without invoking the requested tool.
|
||||
- Apply the guarded invocation behavior to both summary and workflow-editor agent pipelines that use this Responses client.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Expose token-by-token upstream streaming to the UI.
|
||||
- Replace Microsoft.Extensions.AI function invocation or implement general JSON Schema validation.
|
||||
- Recover from an incomplete or malformed Responses event stream that has no usable completed output.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. Add `Agent:UseStreaming`, defaulting to `true`, and send summary requests through the matching OpenAI SDK method. The streaming path uses `ResponsesClient.CreateResponseStreamingAsync` and collects the Microsoft.Extensions.AI.OpenAI typed updates into one `ChatResponse`; the non-streaming path uses `ResponsesClient.CreateResponseAsync` and its supported `AsChatResponse` adapter.
|
||||
|
||||
2. Keep the existing JSON payload builder because it owns Meeting Assistant-specific compaction input, tool serialization, reasoning settings, retry diagnostics, and initiator behavior. Deserialize that payload into the OpenAI SDK's `CreateResponseOptions`, set its streaming flag from configuration, and let the selected SDK method own the HTTP response protocol.
|
||||
|
||||
The SDK deserializes `ResponseItem` values by their `type` discriminator. Message items must therefore include `"type": "message"` before the JSON payload is converted to `CreateResponseOptions`; otherwise the SDK reserializes them as `"type": "unknown"` and the provider rejects the request.
|
||||
|
||||
3. Rely on the framework adapter's `FunctionCallContent.CreateFromParsedArguments` mapping for function-call argument decoding and raw response preservation. A shared function-invocation callback checks the recorded parse exception before invocation. For invalid JSON it returns a structured `invalid_tool_arguments` value that the framework associates with the original call ID; otherwise it delegates to the actual function.
|
||||
|
||||
4. Configure both agent pipelines with the shared guarded invoker. This avoids duplicating the safety decision and prevents zero-argument tools from accidentally running when malformed JSON would otherwise map to an empty argument set.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Provider event variants]** A provider could emit events outside the OpenAI Responses schema. → Use the maintained OpenAI SDK parser and fail diagnostically for genuinely incompatible streams instead of maintaining local event variants.
|
||||
- **[Buffered result]** The summary pipeline does not expose token-by-token updates to its caller. → Collect the framework updates only at the existing non-streaming boundary; the HTTP response itself remains streamed and incrementally parsed.
|
||||
- **[Experimental API]** The OpenAI Responses adapter is marked experimental in the currently referenced package. → Keep it behind `LiteLlmResponsesChatClient`, which isolates future package API changes from the rest of Meeting Assistant.
|
||||
- **[Error disclosure]** Raw parser exceptions may contain implementation details. → Return a stable error code and concise validation message rather than exception text or the malformed arguments.
|
||||
@@ -1,26 +0,0 @@
|
||||
## Why
|
||||
|
||||
The configured LiteLLM Responses endpoint can return successful responses as Server-Sent Events even when the request sets `stream: false`. Meeting Assistant currently treats every successful body as one JSON document, and it also lets malformed function-call argument JSON escape as a fatal parse exception, so either condition can abort an otherwise recoverable summary run.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Consume the configured LiteLLM Responses endpoint through the supported OpenAI Responses streaming transport and Agent Framework adapter.
|
||||
- Assemble completed response output items and usage metadata from Responses SSE events before returning them through the existing non-streaming chat-client interface.
|
||||
- Add an agent setting that selects streaming or non-streaming Responses transport, with streaming enabled by default.
|
||||
- Preserve valid Responses item discriminators when translating agent messages through the OpenAI SDK request model.
|
||||
- Treat malformed function-call arguments as invalid tool input that is returned to the agent for correction without invoking the tool or terminating the summary run.
|
||||
- Add behavior tests for streamed text, streamed function calls, and invalid function-call arguments.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `meeting-summary`: Make the existing Responses-based summary pipeline interoperable with SSE responses and resilient to malformed tool-call input.
|
||||
|
||||
## Impact
|
||||
|
||||
The change affects agent configuration, the custom LiteLLM Responses chat client, its summary-agent integration, and focused tests. It does not change the public HTTP API.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user