Files
meeting-assistant/MeetingAssistant/Recording/OfflineTranscriptionBacklogProcessor.cs
codex aecef30627
PR and Push Build/Test / build-and-test (push) Successful in 9m19s
Archive completed meeting assistant changes
2026-06-26 13:15:47 +02:00

221 lines
8.5 KiB
C#

using MeetingAssistant.LaunchProfiles;
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Summary;
using MeetingAssistant.Transcription;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Recording;
public sealed class OfflineTranscriptionBacklogProcessor
{
private readonly IOfflineTranscriptionBacklog backlog;
private readonly ISpeechRecognitionPipelineFactory pipelineFactory;
private readonly ITranscriptStore transcriptStore;
private readonly IMeetingNoteStore meetingNoteStore;
private readonly IMeetingArtifactStore meetingArtifactStore;
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly IMeetingWorkflowEngine workflowEngine;
private readonly IRecordingDictationWordProvider dictationWordProvider;
private readonly MeetingAssistantOptions options;
private readonly ILogger<OfflineTranscriptionBacklogProcessor> logger;
private readonly ILaunchProfileOptionsProvider? launchProfiles;
public OfflineTranscriptionBacklogProcessor(
IOfflineTranscriptionBacklog backlog,
ISpeechRecognitionPipelineFactory pipelineFactory,
ITranscriptStore transcriptStore,
IMeetingNoteStore meetingNoteStore,
IMeetingArtifactStore meetingArtifactStore,
IMeetingSummaryPipeline summaryPipeline,
IMeetingWorkflowEngine workflowEngine,
IRecordingDictationWordProvider dictationWordProvider,
IOptions<MeetingAssistantOptions> options,
ILogger<OfflineTranscriptionBacklogProcessor> logger,
ILaunchProfileOptionsProvider? launchProfiles = null)
{
this.backlog = backlog;
this.pipelineFactory = pipelineFactory;
this.transcriptStore = transcriptStore;
this.meetingNoteStore = meetingNoteStore;
this.meetingArtifactStore = meetingArtifactStore;
this.summaryPipeline = summaryPipeline;
this.workflowEngine = workflowEngine;
this.dictationWordProvider = dictationWordProvider;
this.options = options.Value;
this.logger = logger;
this.launchProfiles = launchProfiles;
}
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
{
var processed = 0;
foreach (var item in await backlog.ListAsync(cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
if (await TryProcessAsync(item, cancellationToken))
{
processed++;
}
}
return processed;
}
private async Task<bool> TryProcessAsync(
OfflineTranscriptionBacklogItem item,
CancellationToken cancellationToken)
{
try
{
await ProcessAsync(item, cancellationToken);
await backlog.CompleteAsync(item, cancellationToken);
logger.LogInformation(
"Completed offline transcription backlog item {BacklogItemId} for recording {RecordingPath}",
item.Id,
item.AudioPath);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Could not process offline transcription backlog item {BacklogItemId}; it will be retried later",
item.Id);
return false;
}
}
private async Task ProcessAsync(
OfflineTranscriptionBacklogItem item,
CancellationToken cancellationToken)
{
var runOptions = ResolveOptions(item.LaunchProfileName);
var artifacts = new MeetingSessionArtifacts(
item.MeetingNotePath,
item.TranscriptPath,
item.AssistantContextPath,
item.SummaryPath);
var session = new TranscriptSession(item.TranscriptPath);
var meetingNote = await meetingNoteStore.ReadAsync(item.MeetingNotePath, cancellationToken);
var pipelineOptions = await RecordingSpeechRecognitionPipelineOptions.BuildAsync(
dictationWordProvider,
runOptions,
meetingNote,
cancellationToken);
await using var pipeline = pipelineFactory.Create(item.LaunchProfileName);
await pipeline.InitializeAsync(pipelineOptions, cancellationToken);
await pipeline.WaitUntilReadyAsync(cancellationToken);
await WriteRecordingToPipelineAsync(item.AudioPath, pipeline, cancellationToken);
var finishedSegments = await pipeline.ReadFinishedTranscriptAsync(
item.AudioPath,
pipelineOptions,
cancellationToken);
var lines = await TransformTranscriptLinesAsync(artifacts, runOptions, finishedSegments, cancellationToken);
await transcriptStore.ReplaceLinesAsync(session, lines, cancellationToken);
meetingNote.Frontmatter.EndTime = item.InferredEndTime ?? DateTimeOffset.Now;
var completedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
await transcriptStore.UpdateMetadataAsync(session, artifacts, completedMeetingNote, cancellationToken);
await meetingArtifactStore.UpdateAssistantContextMeetingAsync(artifacts, completedMeetingNote, cancellationToken);
await TransitionMeetingAsync(
artifacts,
runOptions,
AssistantContextState.Transcribing,
AssistantContextState.Summarizing,
cancellationToken);
var summaryResult = await summaryPipeline.RunAsync(artifacts, runOptions, cancellationToken);
await TransitionMeetingAsync(
artifacts,
runOptions,
AssistantContextState.Summarizing,
summaryResult.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
cancellationToken);
}
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
{
if (launchProfiles is not null)
{
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
}
return options;
}
private async Task<List<string>> TransformTranscriptLinesAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
IReadOnlyList<TranscriptionSegment> segments,
CancellationToken cancellationToken)
{
var lines = new List<string>(segments.Count);
foreach (var segment in segments)
{
var formatted = TranscriptLineFormatter.Format(segment);
lines.Add(await TransformTranscriptLineAsync(artifacts, runOptions, formatted, cancellationToken));
}
return lines;
}
private async Task<string> TransformTranscriptLineAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
FormattedTranscriptLine formatted,
CancellationToken cancellationToken)
{
try
{
return await workflowEngine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(artifacts, formatted.Line, formatted.Speaker),
runOptions,
cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Offline transcript line workflow failed for speaker {Speaker}; keeping original transcript line",
formatted.Speaker);
return formatted.Line;
}
}
private async Task TransitionMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions runOptions,
AssistantContextState from,
AssistantContextState to,
CancellationToken cancellationToken)
{
await meetingArtifactStore.UpdateAssistantContextStateAsync(artifacts, to, cancellationToken);
await workflowEngine.RunAsync(
MeetingWorkflowEvent.StateTransition(artifacts, from, to),
runOptions,
cancellationToken);
}
private static async Task WriteRecordingToPipelineAsync(
string audioPath,
ISpeechRecognitionPipeline pipeline,
CancellationToken cancellationToken)
{
await foreach (var chunk in PcmWavAudioChunkReader.ReadChunksAsync(audioPath, cancellationToken: cancellationToken))
{
await pipeline.WriteAsync(chunk, cancellationToken);
}
}
}