Public Access
Add meeting inactivity safeguards
PR and Push Build/Test / build-and-test (push) Failing after 14m53s
PR and Push Build/Test / build-and-test (push) Failing after 14m53s
This commit is contained in:
@@ -40,7 +40,11 @@ builder.Services.AddSingleton<IRecordedAudioStore, TemporaryRecordedAudioStore>(
|
||||
builder.Services.AddSingleton<IDictationWordStore, MarkdownDictationWordStore>();
|
||||
builder.Services.AddSingleton<IMeetingInactivityClock, SystemMeetingInactivityClock>();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, WindowsMeetingInactivityPromptService>();
|
||||
builder.Services.AddSingleton<WindowsMeetingInactivityPromptService>();
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService>(services =>
|
||||
services.GetRequiredService<WindowsMeetingInactivityPromptService>());
|
||||
builder.Services.AddHostedService(services =>
|
||||
services.GetRequiredService<WindowsMeetingInactivityPromptService>());
|
||||
#else
|
||||
builder.Services.AddSingleton<IMeetingInactivityPromptService, NoopMeetingInactivityPromptService>();
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal static class MeetingArtifactContentDetector
|
||||
{
|
||||
private static readonly string[] DefaultAssistantContextLines =
|
||||
[
|
||||
"# Assistant Context",
|
||||
"## Live Context"
|
||||
];
|
||||
|
||||
public static async Task<bool> IsDefaultOnlyAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(artifacts.MeetingNotePath) ||
|
||||
!File.Exists(artifacts.TranscriptPath) ||
|
||||
!File.Exists(artifacts.AssistantContextPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var meetingNote = await File.ReadAllTextAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
var transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
|
||||
var assistantContext = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
|
||||
|
||||
return IsDefaultOnlyMeetingNote(meetingNote) &&
|
||||
IsDefaultOnlyTranscript(transcript) &&
|
||||
IsDefaultOnlyAssistantContext(assistantContext);
|
||||
}
|
||||
|
||||
private static bool IsDefaultOnlyMeetingNote(string content)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(MarkdownDocumentParser.SplitOptional(content).Body);
|
||||
}
|
||||
|
||||
private static bool IsDefaultOnlyTranscript(string content)
|
||||
{
|
||||
var body = MarkdownDocumentParser.SplitOptional(content).Body;
|
||||
return GetMeaningfulLines(body)
|
||||
.All(line => string.Equals(line, "# Meeting Transcript", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static bool IsDefaultOnlyAssistantContext(string content)
|
||||
{
|
||||
var body = MarkdownDocumentParser.SplitOptional(content).Body;
|
||||
return GetMeaningfulLines(body)
|
||||
.All(line => DefaultAssistantContextLines.Contains(line, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetMeaningfulLines(string body)
|
||||
{
|
||||
return body
|
||||
.Split(["\r\n", "\n"], StringSplitOptions.None)
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line));
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,14 @@ public sealed class MeetingRecordingCoordinator
|
||||
run.TranscriptionCancellation);
|
||||
}
|
||||
|
||||
if (await MeetingArtifactContentDetector.IsDefaultOnlyAsync(artifacts, run.TranscriptionCancellation))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Deleting stopped meeting artifacts instead of summarizing because note, transcript, and assistant context contain only generated defaults");
|
||||
await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, run.TranscriptionCancellation);
|
||||
return;
|
||||
}
|
||||
|
||||
var summaryState = await RunSummaryAsync(run, run.TranscriptionCancellation);
|
||||
await CompletePendingFinalSpeakerIdentificationAsync(run, run.TranscriptionCancellation);
|
||||
await TransitionMeetingAsync(run, summaryState, CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal static class NotificationActivationArguments
|
||||
{
|
||||
public static IReadOnlyDictionary<string, string> Parse(string argumentText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argumentText))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in argumentText.Split(['&', ';'], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var separatorIndex = pair.IndexOf('=');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
arguments[Uri.UnescapeDataString(pair)] = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
|
||||
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
|
||||
arguments[key] = value;
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using CommunityToolkit.WinUI.Notifications;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IDisposable
|
||||
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IHostedService, IDisposable
|
||||
{
|
||||
private const string NotificationSource = "meeting-inactivity-safeguard";
|
||||
private const string NotificationGroup = "meeting-inactivity";
|
||||
@@ -39,6 +39,10 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
EnsureRegistered();
|
||||
var promptId = Guid.NewGuid().ToString("N");
|
||||
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
||||
logger.LogInformation(
|
||||
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
||||
promptId,
|
||||
request.Threshold);
|
||||
var notification = BuildNotification(promptId, request);
|
||||
notification.Show(toast =>
|
||||
{
|
||||
@@ -59,6 +63,23 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
|
||||
{
|
||||
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
EnsureRegistered();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!registered)
|
||||
@@ -69,11 +90,10 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
||||
ToastNotificationManagerCompat.Uninstall();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Failed to unregister native Windows toast notifications");
|
||||
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +146,31 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
|
||||
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = ParseArguments(args.Argument);
|
||||
logger.LogInformation(
|
||||
"Native Windows inactivity notification activation received with arguments {Arguments}",
|
||||
args.Argument);
|
||||
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (!TryGetArgument(arguments, "source", out var source) ||
|
||||
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase) ||
|
||||
!TryGetArgument(arguments, "promptId", out var promptId) ||
|
||||
!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
|
||||
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Ignoring native Windows notification activation for unrelated source {Source}",
|
||||
source);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryGetArgument(arguments, "promptId", out var promptId))
|
||||
{
|
||||
logger.LogWarning("Ignoring native Windows inactivity notification activation without prompt id");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Ignoring native Windows inactivity notification {PromptId} activation because no pending prompt callback exists",
|
||||
promptId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,31 +198,6 @@ public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPr
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ParseArguments(string argumentText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argumentText))
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var arguments = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in argumentText.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var separatorIndex = pair.IndexOf('=');
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
arguments[Uri.UnescapeDataString(pair)] = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Uri.UnescapeDataString(pair[..separatorIndex]);
|
||||
var value = Uri.UnescapeDataString(pair[(separatorIndex + 1)..]);
|
||||
arguments[key] = value;
|
||||
}
|
||||
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private static bool TryGetArgument(
|
||||
IReadOnlyDictionary<string, string> arguments,
|
||||
string key,
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
|
||||
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. The write_summary call requires an oneliner parameter: keep it very short, blurb-like, and concise. No line breaks. Complete sentences are not necessary; conciseness is more important. write_summary returns the written summary filename so project journal entries can link to it.
|
||||
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, provide a concise title parameter to write_summary.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -219,9 +219,14 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
|
||||
var meetingNote = await ReadMeetingNoteAsync();
|
||||
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
? title
|
||||
: meetingNote.Frontmatter.Title;
|
||||
var trimmedTitle = title?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(trimmedTitle))
|
||||
{
|
||||
meetingNote.Frontmatter.Title = trimmedTitle;
|
||||
await WriteMeetingNoteAsync(meetingNote);
|
||||
}
|
||||
|
||||
var summaryTitle = meetingNote.Frontmatter.Title;
|
||||
var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync(
|
||||
artifacts,
|
||||
meetingNote,
|
||||
|
||||
@@ -171,7 +171,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteSummary,
|
||||
"write_summary",
|
||||
"Write the finished summary markdown to the configured summary note and return the written summary filename. Provide title when the meeting note has no title."),
|
||||
"Write the finished summary markdown to the configured summary note and return the written summary filename. Provide title to rename the meeting when the current meeting title is missing or is still a generated default and the meeting purpose is clear."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListProjects,
|
||||
"list_projects",
|
||||
|
||||
Reference in New Issue
Block a user