Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
@@ -2170,8 +2170,8 @@ public sealed class RecordingCoordinatorTests
|
||||
Assert.Equal(1, processed);
|
||||
Assert.Empty(backlog.Items);
|
||||
Assert.Equal(["english"], pipelineFactory.ProfileNames);
|
||||
var segment = Assert.Single(transcriptStore.ReplacedSegments);
|
||||
Assert.Contains("english chunk:4", segment.Text, StringComparison.Ordinal);
|
||||
Assert.Single(transcriptStore.Segments, segment => segment.Text.Contains("english chunk:4", StringComparison.Ordinal));
|
||||
Assert.Contains(transcriptStore.Segments, segment => segment.Text.Contains("may duplicate earlier transcript passages", StringComparison.Ordinal));
|
||||
Assert.Equal(stoppedAt, noteStore.SavedNote?.Frontmatter.EndTime);
|
||||
Assert.True(summaryPipeline.WasRun);
|
||||
Assert.Equal(
|
||||
@@ -2182,6 +2182,167 @@ public sealed class RecordingCoordinatorTests
|
||||
workflowEvent.TranscriptLineText?.Contains("english chunk:4", StringComparison.Ordinal) == true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" \t", false)]
|
||||
[InlineData("<Reconnecting... 1/5>", true)]
|
||||
public async Task OfflineReplayWithoutSpeechPreservesTranscriptAndRecoveryFilesUntilSuccessfulRetry(
|
||||
string? replayText, bool isMarker)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var audioPath = Path.Combine(root, "queued.wav");
|
||||
using (var writer = new NAudio.Wave.WaveFileWriter(audioPath, new NAudio.Wave.WaveFormat(16000, 16, 1)))
|
||||
{
|
||||
writer.Write([1, 0, 2, 0], 0, 4);
|
||||
}
|
||||
|
||||
var options = Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Vault = new VaultOptions { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts" },
|
||||
Recording = new RecordingOptions { TemporaryRecordingsFolder = root }
|
||||
});
|
||||
var transcriptStore = new VaultTranscriptStore(options, NullLogger<VaultTranscriptStore>.Instance);
|
||||
var noteStore = new MarkdownMeetingNoteStore(options, NullLogger<MarkdownMeetingNoteStore>.Instance);
|
||||
var artifactStore = new InMemoryMeetingArtifactStore();
|
||||
var summaryPipeline = new TranscriptReadingSummaryPipeline();
|
||||
var workflowEngine = new CapturingMeetingWorkflowEngine();
|
||||
var backlog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
|
||||
var startedAt = DateTimeOffset.Now.AddMinutes(-5);
|
||||
var stoppedAt = startedAt.AddMinutes(4);
|
||||
var session = await transcriptStore.CreateSessionAsync(options.Value, startedAt, CancellationToken.None);
|
||||
var artifacts = new MeetingSessionArtifacts(
|
||||
Path.Combine(root, "meeting.md"), session.TranscriptPath,
|
||||
Path.Combine(root, "context.md"), Path.Combine(root, "summary.md"));
|
||||
var note = await noteStore.SaveAsync(new MeetingNote(
|
||||
artifacts.MeetingNotePath,
|
||||
new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = "Queued meeting", StartTime = startedAt,
|
||||
Transcript = session.TranscriptPath,
|
||||
AssistantContext = artifacts.AssistantContextPath,
|
||||
Summary = artifacts.SummaryPath
|
||||
},
|
||||
"Existing user notes"), CancellationToken.None);
|
||||
await transcriptStore.UpdateMetadataAsync(session, artifacts, note, CancellationToken.None);
|
||||
await transcriptStore.AppendLineAsync(session, "[00:00:01] Ada: Preserve this live transcript.", CancellationToken.None);
|
||||
await File.AppendAllTextAsync(session.TranscriptPath, "User correction: keep the original speaker names.");
|
||||
var originalTranscript = await File.ReadAllBytesAsync(session.TranscriptPath);
|
||||
var originalBody = MeetingArtifactFrontmatterRenderer.Split(await File.ReadAllTextAsync(session.TranscriptPath)).Body;
|
||||
var originalNote = await File.ReadAllBytesAsync(note.Path);
|
||||
var originalAudio = await File.ReadAllBytesAsync(audioPath);
|
||||
var item = new OfflineTranscriptionBacklogItem(
|
||||
"queued", audioPath, session.TranscriptPath, note.Path,
|
||||
artifacts.AssistantContextPath, artifacts.SummaryPath, startedAt, stoppedAt, "default");
|
||||
await backlog.EnqueueAsync(item, CancellationToken.None);
|
||||
var replaySegments = new List<TranscriptionSegment>();
|
||||
if (replayText is not null)
|
||||
{
|
||||
replaySegments.Add(new TranscriptionSegment(
|
||||
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Unknown", replayText,
|
||||
isMarker ? TranscriptionSegmentKind.Marker : TranscriptionSegmentKind.Speech));
|
||||
}
|
||||
|
||||
var processor = new OfflineTranscriptionBacklogProcessor(
|
||||
backlog,
|
||||
new TestSpeechRecognitionPipelineFactory(new SequencedStreamingTranscriptionProvider(replaySegments)),
|
||||
transcriptStore, noteStore, artifactStore, summaryPipeline, workflowEngine,
|
||||
CreateRecordingDictationWordProvider(new FixedDictationWordStore([])), options,
|
||||
NullLogger<OfflineTranscriptionBacklogProcessor>.Instance);
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var processed = await processor.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(originalTranscript, await File.ReadAllBytesAsync(session.TranscriptPath));
|
||||
Assert.Equal(0, processed);
|
||||
Assert.Equal(originalNote, await File.ReadAllBytesAsync(note.Path));
|
||||
Assert.Equal(originalAudio, await File.ReadAllBytesAsync(audioPath));
|
||||
var reloadedBacklog = new FileOfflineTranscriptionBacklog(options, NullLogger<FileOfflineTranscriptionBacklog>.Instance);
|
||||
Assert.Equal(item, Assert.Single(await reloadedBacklog.ListAsync(CancellationToken.None)));
|
||||
Assert.Null(summaryPipeline.Transcript);
|
||||
Assert.Empty(artifactStore.States);
|
||||
Assert.Empty(workflowEngine.Events);
|
||||
}
|
||||
|
||||
replaySegments.Clear();
|
||||
replaySegments.Add(new TranscriptionSegment(
|
||||
TimeSpan.Zero, TimeSpan.FromSeconds(1), "Ada", "Preserve this live transcript. Recovered speech."));
|
||||
|
||||
Assert.Equal(1, await processor.ProcessPendingAsync(CancellationToken.None));
|
||||
var recoveredTranscript = await File.ReadAllTextAsync(session.TranscriptPath);
|
||||
Assert.Contains("Recovered speech.", recoveredTranscript, StringComparison.Ordinal);
|
||||
var recoveredBody = MeetingArtifactFrontmatterRenderer.Split(recoveredTranscript).Body;
|
||||
Assert.StartsWith(originalBody, recoveredBody, StringComparison.Ordinal);
|
||||
Assert.Equal(2, recoveredBody.Split("Preserve this live transcript.", StringSplitOptions.None).Length - 1);
|
||||
var appended = recoveredBody[originalBody.Length..];
|
||||
Assert.StartsWith(Environment.NewLine + "## Offline transcription recovery", appended, StringComparison.Ordinal);
|
||||
Assert.Contains("may duplicate earlier transcript passages", appended, StringComparison.Ordinal);
|
||||
Assert.Contains("Treat repeated passages as the same discussion when summarizing", appended, StringComparison.Ordinal);
|
||||
Assert.True(appended.IndexOf("may duplicate", StringComparison.Ordinal) < appended.IndexOf("Recovered speech.", StringComparison.Ordinal));
|
||||
Assert.Equal(stoppedAt, (await noteStore.ReadAsync(note.Path, CancellationToken.None)).Frontmatter.EndTime);
|
||||
Assert.Equal(recoveredTranscript, summaryPipeline.Transcript);
|
||||
Assert.Equal(new[] { AssistantContextState.Summarizing, AssistantContextState.Finished }, artifactStore.States);
|
||||
Assert.Empty(await backlog.ListAsync(CancellationToken.None));
|
||||
Assert.False(File.Exists(audioPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopTimeoutKeepsSummaryApprovalAliveWithoutQueuingOfflineReplay()
|
||||
{
|
||||
var audioSource = new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0, 2, 0], 16000, 1));
|
||||
var summary = new WaitingForApprovalSummaryPipeline();
|
||||
var backlog = new InMemoryOfflineTranscriptionBacklog();
|
||||
var artifacts = new InMemoryMeetingArtifactStore();
|
||||
var coordinator = new MeetingRecordingCoordinator(
|
||||
audioSource, new TestSpeechRecognitionPipelineFactory(new FinalSegmentOnAudioCompletionProvider()),
|
||||
new InMemoryTranscriptStore(), new InMemoryMeetingNoteStore(), new CapturingMeetingNoteOpener(),
|
||||
artifacts, new InMemoryRecordedAudioStore(), summary,
|
||||
Options.Create(new MeetingAssistantOptions
|
||||
{
|
||||
Recording = new RecordingOptions
|
||||
{
|
||||
TranscriptionProvider = "azure-speech",
|
||||
StopProcessingTimeout = TimeSpan.FromMilliseconds(500)
|
||||
}
|
||||
}), NullLogger<MeetingRecordingCoordinator>.Instance, offlineTranscriptionBacklog: backlog);
|
||||
await coordinator.StartAsync(CancellationToken.None);
|
||||
await audioSource.WaitUntilCapturedAsync();
|
||||
var stopping = coordinator.StopAsync(CancellationToken.None);
|
||||
await summary.Started.Task.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
var status = await stopping.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
Assert.False(status.IsRecording);
|
||||
Assert.False(summary.Cancellation.IsCancellationRequested);
|
||||
Assert.Empty(backlog.Items);
|
||||
}
|
||||
finally { summary.Release.TrySetResult(); }
|
||||
await WaitUntilAsync(() => artifacts.States.Contains(AssistantContextState.Finished), "Approved summary did not finish.");
|
||||
}
|
||||
|
||||
private sealed class WaitingForApprovalSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
public CancellationToken Cancellation { get; private set; }
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken)
|
||||
{
|
||||
Cancellation = cancellationToken;
|
||||
Started.TrySetResult();
|
||||
await Release.Task.WaitAsync(cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "Approved and completed");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbortStopsRecordingDeletesArtifactsAndSkipsSummary()
|
||||
{
|
||||
@@ -4548,6 +4709,19 @@ public sealed class RecordingCoordinatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TranscriptReadingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
public string? Transcript { get; private set; }
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Transcript = await File.ReadAllTextAsync(artifacts.TranscriptPath, cancellationToken);
|
||||
return new MeetingSummaryRunResult(artifacts.SummaryPath, "summary ok", true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CapturingMeetingSummaryPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
private readonly bool succeeded;
|
||||
|
||||
Reference in New Issue
Block a user