Archive meeting workflow and screenshot changes

This commit is contained in:
2026-05-27 12:55:19 +02:00
parent 2422236ef7
commit 12832dde84
48 changed files with 3041 additions and 43 deletions
@@ -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;
}
}
}
}