Public Access
Archive meeting workflow and screenshot changes
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Collections.Concurrent;
|
||||
using MeetingAssistant.LaunchProfiles;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Speakers;
|
||||
using MeetingAssistant.Screenshots;
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Transcription;
|
||||
using MeetingAssistant.Workflow;
|
||||
@@ -26,6 +27,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
private readonly ILaunchProfileOptionsProvider? launchProfiles;
|
||||
private readonly IMeetingWorkflowEngine meetingWorkflowEngine;
|
||||
private readonly IMeetingScreenshotService screenshotService;
|
||||
private readonly IMeetingRunArtifactCleaner artifactCleaner;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<MeetingRecordingCoordinator> logger;
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
@@ -50,7 +53,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
IDictationWordStore? dictationWordStore = null,
|
||||
ISpeakerIdentityAttendeeCanonicalizer? attendeeCanonicalizer = null,
|
||||
ILaunchProfileOptionsProvider? launchProfiles = null,
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null)
|
||||
IMeetingWorkflowEngine? meetingWorkflowEngine = null,
|
||||
IMeetingScreenshotService? screenshotService = null,
|
||||
IMeetingRunArtifactCleaner? artifactCleaner = null)
|
||||
{
|
||||
this.audioSource = audioSource;
|
||||
this.speechRecognitionPipelineFactory = speechRecognitionPipelineFactory;
|
||||
@@ -66,6 +71,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
this.attendeeCanonicalizer = attendeeCanonicalizer ?? PassthroughSpeakerIdentityAttendeeCanonicalizer.Instance;
|
||||
this.launchProfiles = launchProfiles;
|
||||
this.meetingWorkflowEngine = meetingWorkflowEngine ?? NoopMeetingWorkflowEngine.Instance;
|
||||
this.screenshotService = screenshotService ?? NoopMeetingScreenshotService.Instance;
|
||||
this.artifactCleaner = artifactCleaner ?? new MeetingRunArtifactCleaner();
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
}
|
||||
@@ -230,6 +237,87 @@ public sealed class MeetingRecordingCoordinator
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> AbortAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RecordingRun run;
|
||||
MeetingSessionArtifacts artifacts;
|
||||
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (currentRun is null)
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
run = currentRun;
|
||||
artifacts = run.Artifacts;
|
||||
run.Abort();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await run.Task.WaitAsync(run.Options.Recording.StopProcessingTimeout, cancellationToken);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
logger.LogWarning("Timed out while aborting meeting recording; forcing transcription cancellation");
|
||||
run.CancelTranscription();
|
||||
await run.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await screenshotService.CancelPendingOcrAsync(
|
||||
artifacts,
|
||||
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
|
||||
cancellationToken);
|
||||
await artifactCleaner.DeleteRunArtifactsAsync(artifacts, run.Options, cancellationToken);
|
||||
logger.LogInformation("Meeting recording aborted and artifacts deleted");
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
public Task<MeetingScreenshotCaptureResult> CaptureScreenshotAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureScreenshotAsync(null, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<MeetingScreenshotCaptureResult> CaptureScreenshotAsync(
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MeetingSessionArtifacts artifacts;
|
||||
DateTimeOffset? meetingStartedAt;
|
||||
MeetingAssistantOptions runOptions;
|
||||
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (currentRun is not { IsCaptureStopping: false, IsAborted: false } run ||
|
||||
currentMeetingNote is null)
|
||||
{
|
||||
throw new InvalidOperationException("No active meeting is available for screenshot capture.");
|
||||
}
|
||||
|
||||
artifacts = run.Artifacts;
|
||||
meetingStartedAt = currentMeetingNote.Frontmatter.StartTime;
|
||||
runOptions = run.Options;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
|
||||
return await screenshotService.CaptureAsync(
|
||||
artifacts,
|
||||
meetingStartedAt,
|
||||
DateTimeOffset.Now,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RecordAsync(RecordingRun run)
|
||||
{
|
||||
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
||||
@@ -457,11 +545,21 @@ public sealed class MeetingRecordingCoordinator
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, CancellationToken.None);
|
||||
await ApplyMeetingMetadataAsync(meetingNote, metadata, CancellationToken.None);
|
||||
await ApplyMeetingMetadataAsync(meetingNote, metadata, run.Options, CancellationToken.None);
|
||||
meetingNote = await meetingNoteStore.SaveAsync(meetingNote, run.Options, CancellationToken.None);
|
||||
if (currentMeetingNote?.Path == meetingNote.Path)
|
||||
{
|
||||
@@ -494,10 +592,13 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
try
|
||||
{
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Transcribing,
|
||||
CancellationToken.None);
|
||||
if (!run.IsAborted)
|
||||
{
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Transcribing,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -512,6 +613,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
private async Task ApplyMeetingMetadataAsync(
|
||||
MeetingNote meetingNote,
|
||||
MeetingMetadata metadata,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
@@ -521,6 +623,16 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
if (metadata.Attendees.Count > 0)
|
||||
{
|
||||
var attendeeImportLimit = Math.Max(0, options.Recording.MaxMetadataAttendeeImportCount);
|
||||
if (metadata.Attendees.Count > attendeeImportLimit)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Skipped importing {AttendeeCount} metadata attendees because it exceeds the configured limit {AttendeeImportLimit}",
|
||||
metadata.Attendees.Count,
|
||||
attendeeImportLimit);
|
||||
return;
|
||||
}
|
||||
|
||||
meetingNote.Frontmatter.Attendees = await CanonicalizeAttendeesAsync(metadata.Attendees, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -835,8 +947,17 @@ public sealed class MeetingRecordingCoordinator
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await screenshotService.WaitForPendingOcrAsync(
|
||||
run.Artifacts,
|
||||
run.Options.Screenshots.Ocr.GetEffectiveTimeout(),
|
||||
cancellationToken);
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Summarizing,
|
||||
@@ -866,6 +987,11 @@ public sealed class MeetingRecordingCoordinator
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!run.TryTransitionTo(state, out var fromState))
|
||||
{
|
||||
return;
|
||||
@@ -1038,6 +1164,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public bool IsCaptureStopping => CaptureCancellationSource.IsCancellationRequested;
|
||||
|
||||
public bool IsAborted { get; private set; }
|
||||
|
||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||
|
||||
public void StopCapture()
|
||||
@@ -1045,6 +1173,14 @@ public sealed class MeetingRecordingCoordinator
|
||||
CaptureCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public void Abort()
|
||||
{
|
||||
IsAborted = true;
|
||||
CaptureCancellationSource.Cancel();
|
||||
TranscriptionCancellationSource.Cancel();
|
||||
LiveIdentificationCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public void CancelTranscription()
|
||||
{
|
||||
TranscriptionCancellationSource.Cancel();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMeetingRunArtifactCleaner
|
||||
{
|
||||
Task DeleteRunArtifactsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MeetingRunArtifactCleaner : IMeetingRunArtifactCleaner
|
||||
{
|
||||
private static readonly Regex MarkdownImageLinkPattern = new("!\\[[^\\]]*\\]\\((?<path>[^)]+)\\)", RegexOptions.Compiled);
|
||||
private static readonly Regex GeneratedScreenshotFileNamePattern = new(
|
||||
@"^\d{8}-\d{6}-\d{3}-\d{6}(?:-cropped)?\.png$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
private readonly ILogger<MeetingRunArtifactCleaner>? logger;
|
||||
|
||||
public MeetingRunArtifactCleaner(ILogger<MeetingRunArtifactCleaner>? logger = null)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task DeleteRunArtifactsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var linkedFiles = await GetLinkedScreenshotAttachmentFilesAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
options.Screenshots.AttachmentsFolder,
|
||||
cancellationToken);
|
||||
foreach (var linkedFile in linkedFiles)
|
||||
{
|
||||
await DeleteFileIfExistsAsync(linkedFile, cancellationToken);
|
||||
}
|
||||
|
||||
await DeleteFileIfExistsAsync(artifacts.SummaryPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.AssistantContextPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.TranscriptPath, cancellationToken);
|
||||
await DeleteFileIfExistsAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<string>> GetLinkedScreenshotAttachmentFilesAsync(
|
||||
string assistantContextPath,
|
||||
string configuredAttachmentsFolder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var contextDirectory = Path.GetDirectoryName(Path.GetFullPath(assistantContextPath));
|
||||
if (string.IsNullOrWhiteSpace(contextDirectory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var attachmentsFolder = ResolveAttachmentsFolder(contextDirectory, configuredAttachmentsFolder);
|
||||
return MarkdownImageLinkPattern
|
||||
.Matches(content)
|
||||
.Select(match => match.Groups["path"].Value.Trim())
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Select(path => ResolveScreenshotAttachmentLink(contextDirectory, attachmentsFolder, path))
|
||||
.Where(path => path is not null)
|
||||
.Select(path => path!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? ResolveScreenshotAttachmentLink(
|
||||
string contextDirectory,
|
||||
string attachmentsFolder,
|
||||
string linkPath)
|
||||
{
|
||||
if (Uri.TryCreate(linkPath, UriKind.Absolute, out var uri) && !uri.IsFile)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var withoutFragment = linkPath.Split('#')[0].Split('?')[0];
|
||||
var decoded = Uri.UnescapeDataString(withoutFragment);
|
||||
var fullPath = Path.IsPathRooted(decoded)
|
||||
? Path.GetFullPath(decoded)
|
||||
: Path.GetFullPath(Path.Combine(contextDirectory, decoded));
|
||||
var attachmentsRoot = Path.GetFullPath(attachmentsFolder).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) +
|
||||
Path.DirectorySeparatorChar;
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
return fullPath.StartsWith(attachmentsRoot, StringComparison.OrdinalIgnoreCase) &&
|
||||
GeneratedScreenshotFileNamePattern.IsMatch(fileName)
|
||||
? fullPath
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string ResolveAttachmentsFolder(string contextDirectory, string configuredFolder)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(
|
||||
string.IsNullOrWhiteSpace(configuredFolder) ? "Attachments" : configuredFolder);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(Path.Combine(contextDirectory, expanded));
|
||||
}
|
||||
|
||||
private async Task DeleteFileIfExistsAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow.AddSeconds(2);
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
logger?.LogInformation("Deleted aborted meeting artifact {ArtifactPath}", path);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
catch (UnauthorizedAccessException) when (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
await Task.Delay(25, cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger?.LogWarning(exception, "Could not delete aborted meeting artifact {ArtifactPath}", path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user