Use consistent meeting artifact filenames
PR and Push Build/Test / build-and-test (push) Successful in 10m14s

This commit is contained in:
2026-06-10 16:56:10 +02:00
parent 5bbfd7b3c3
commit e54b19d3a9
8 changed files with 110 additions and 37 deletions
@@ -57,6 +57,19 @@ public sealed class MeetingNoteStoreTests
Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary); Assert.Equal("[[../Summaries/20260519-summary|Summary]]", loaded.Frontmatter.Summary);
} }
[Fact]
public async Task StoreNamesGeneratedMeetingNoteWithMinutePrecision()
{
var (_, store) = CreateStore();
var saved = await store.SaveAsync(
MeetingNoteTemplate.Create(
title: "Leadership Sync",
startTime: DateTimeOffset.Parse("2026-05-19T10:03:42+02:00")),
CancellationToken.None);
Assert.Equal("20260519-1003-note.md", Path.GetFileName(saved.Path));
}
[Fact] [Fact]
public async Task FrontmatterUpdatePreservesExistingUserNotesBody() public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
{ {
@@ -186,6 +186,7 @@ public sealed class RecordingCoordinatorTests
var audioSource = new ControlledAudioSource(); var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md"); var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md");
var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md"); var noteStore = new InMemoryMeetingNoteStore("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md");
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var noteOpener = new CapturingMeetingNoteOpener(); var noteOpener = new CapturingMeetingNoteOpener();
var artifactStore = new InMemoryMeetingArtifactStore(); var artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore(); var audioArchive = new InMemoryRecordedAudioStore();
@@ -206,22 +207,21 @@ public sealed class RecordingCoordinatorTests
SummariesFolder = "C:\\Vault\\Meetings\\Summaries" SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
} }
}), }),
NullLogger<MeetingRecordingCoordinator>.Instance); NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityClock: clock);
var status = await coordinator.StartAsync(CancellationToken.None); var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording); Assert.True(status.IsRecording);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", status.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript); Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", noteStore.SavedNote?.Frontmatter.Transcript);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal); Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal); Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.EndsWith("-summary.md", noteStore.SavedNote?.Frontmatter.Summary, StringComparison.Ordinal);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", noteOpener.OpenedPath);
Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath); Assert.Equal("C:\\Vault\\Meetings\\Notes\\20260519-meeting.md", artifactStore.CreatedArtifacts?.MeetingNotePath);
Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath); Assert.Equal("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal); Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal); Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", artifactStore.CreatedArtifacts?.SummaryPath);
Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title); Assert.Equal(noteStore.SavedNote?.Frontmatter.Title, artifactStore.ContextMeetingNote?.Frontmatter.Title);
Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime); Assert.Equal(noteStore.SavedNote?.Frontmatter.StartTime, artifactStore.ContextMeetingNote?.Frontmatter.StartTime);
@@ -1101,6 +1101,7 @@ public sealed class RecordingCoordinatorTests
var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default"); var defaultRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "default");
var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english"); var englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english");
var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot); var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot);
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var summaryPipeline = new CapturingMeetingSummaryPipeline(); var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator( var coordinator = new MeetingRecordingCoordinator(
new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)), new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)),
@@ -1113,15 +1114,16 @@ public sealed class RecordingCoordinatorTests
summaryPipeline, summaryPipeline,
Options.Create(launchProfiles.GetRequiredProfile(null).Options), Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance, NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles); launchProfiles: launchProfiles,
inactivityClock: clock);
var started = await coordinator.StartAsync("english", CancellationToken.None); var started = await coordinator.StartAsync("english", CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None); await coordinator.StopAsync(CancellationToken.None);
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase); Assert.Equal(Path.Combine(englishRoot, "Transcripts", "20260519-1003-transcript.md"), started.TranscriptPath);
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase); Assert.Equal(Path.Combine(englishRoot, "Notes", "20260519-1003-note.md"), started.MeetingNotePath);
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase); Assert.Equal(Path.Combine(englishRoot, "Context", "20260519-1003-context.md"), started.AssistantContextPath);
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase); Assert.Equal(Path.Combine(englishRoot, "Summaries", "20260519-1003-summary.md"), started.SummaryPath);
Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model); Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model);
} }
@@ -2015,6 +2017,26 @@ public sealed class RecordingCoordinatorTests
Assert.DoesNotContain("transcript:", content); Assert.DoesNotContain("transcript:", content);
} }
[Fact]
public async Task VaultTranscriptStoreNamesGeneratedTranscriptWithMinutePrecision()
{
var vaultFolder = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var options = new MeetingAssistantOptions
{
Vault = new VaultOptions { TranscriptsFolder = vaultFolder }
};
var store = new VaultTranscriptStore(
Options.Create(options),
NullLogger<VaultTranscriptStore>.Instance);
var session = await store.CreateSessionAsync(
options,
DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"),
CancellationToken.None);
Assert.Equal("20260519-1003-transcript.md", Path.GetFileName(session.TranscriptPath));
}
[Fact] [Fact]
public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav() public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav()
{ {
@@ -2159,6 +2181,14 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession(transcriptPath)); return Task.FromResult(new TranscriptSession(transcriptPath));
} }
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{ {
segments.Add(ParseTranscriptLine(line)); segments.Add(ParseTranscriptLine(line));
@@ -2226,6 +2256,14 @@ public sealed class RecordingCoordinatorTests
return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md")); return Task.FromResult(new TranscriptSession($"memory-transcript-{index}.md"));
} }
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
{ {
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line))); AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
@@ -2887,10 +2925,13 @@ public sealed class RecordingCoordinatorTests
public Task<TranscriptSession> CreateSessionAsync( public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options, MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder); var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
return Task.FromResult(new TranscriptSession(Path.Combine(folder, "transcript.md"))); return Task.FromResult(new TranscriptSession(Path.Combine(
folder,
MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript))));
} }
public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken) public Task AppendLineAsync(TranscriptSession session, string line, CancellationToken cancellationToken)
@@ -2931,7 +2972,16 @@ public sealed class RecordingCoordinatorTests
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder); var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
savedNote = note with { Path = string.IsNullOrWhiteSpace(note.Path) ? Path.Combine(folder, "meeting.md") : note.Path }; savedNote = note with
{
Path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(
folder,
MeetingArtifactFileNames.Create(
note.Frontmatter.StartTime ?? DateTimeOffset.Now,
MeetingArtifactFileNames.Note))
: note.Path
};
return Task.FromResult(savedNote); return Task.FromResult(savedNote);
} }
@@ -35,7 +35,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path) var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md") ? Path.Combine(folder, MeetingArtifactFileNames.Create(note.Frontmatter.StartTime ?? DateTimeOffset.Now, MeetingArtifactFileNames.Note))
: note.Path; : note.Path;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path); var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes); var content = Render(frontmatter, note.UserNotes);
@@ -108,16 +108,6 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
return string.Join(Environment.NewLine, lines); return string.Join(Environment.NewLine, lines);
} }
private static string Slugify(string value)
{
var slug = new string(value
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-')
.ToArray());
slug = string.Join("-", slug.Split('-', StringSplitOptions.RemoveEmptyEntries));
return string.IsNullOrWhiteSpace(slug) ? "meeting" : slug;
}
private static string EscapeScalar(string value) private static string EscapeScalar(string value)
{ {
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value); return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
@@ -0,0 +1,14 @@
namespace MeetingAssistant.MeetingNotes;
public static class MeetingArtifactFileNames
{
public const string Note = "note";
public const string Context = "context";
public const string Transcript = "transcript";
public const string Summary = "summary";
public static string Create(DateTimeOffset startedAt, string artifactType)
{
return $"{startedAt:yyyyMMdd-HHmm}-{artifactType}.md";
}
}
@@ -9,10 +9,8 @@ public interface ITranscriptStore
Task<TranscriptSession> CreateSessionAsync( Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options, MeetingAssistantOptions options,
CancellationToken cancellationToken) DateTimeOffset startedAt,
{ CancellationToken cancellationToken);
return CreateSessionAsync(cancellationToken);
}
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken) Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{ {
@@ -156,9 +156,9 @@ public sealed class MeetingRecordingCoordinator
var launchProfile = ResolveProfile(launchProfileName); var launchProfile = ResolveProfile(launchProfileName);
var runOptions = launchProfile.Options; var runOptions = launchProfile.Options;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var startedAt = inactivityClock.Now; var startedAt = inactivityClock.Now;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, startedAt, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions); var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
var summaryPath = GetSummaryPath(startedAt, runOptions); var summaryPath = GetSummaryPath(startedAt, runOptions);
currentMeetingNote = await meetingNoteStore.SaveAsync( currentMeetingNote = await meetingNoteStore.SaveAsync(
@@ -1491,12 +1491,12 @@ public sealed class MeetingRecordingCoordinator
private string GetAssistantContextPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions) private string GetAssistantContextPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
{ {
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, "assistant-context"); return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.AssistantContextFolder, startedAt, MeetingArtifactFileNames.Context);
} }
private string GetSummaryPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions) private string GetSummaryPath(DateTimeOffset startedAt, MeetingAssistantOptions runOptions)
{ {
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, "summary"); return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, MeetingArtifactFileNames.Summary);
} }
private static string GetMeetingArtifactPath( private static string GetMeetingArtifactPath(
@@ -1506,7 +1506,7 @@ public sealed class MeetingRecordingCoordinator
string artifactName) string artifactName)
{ {
var folder = VaultPath.Resolve(vault, configuredFolder); var folder = VaultPath.Resolve(vault, configuredFolder);
return Path.Combine(folder, $"{startedAt:yyyyMMdd-HHmm}-{artifactName}.md"); return Path.Combine(folder, MeetingArtifactFileNames.Create(startedAt, artifactName));
} }
private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt) private static bool ShouldAbortRunOnStop(RecordingRun run, DateTimeOffset stoppedAt)
@@ -17,17 +17,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken) public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{ {
return await CreateSessionAsync(options, cancellationToken); return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken);
} }
public async Task<TranscriptSession> CreateSessionAsync( public async Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options, MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder); var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
var fileName = $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-transcript.md"; var fileName = MeetingArtifactFileNames.Create(startedAt, MeetingArtifactFileNames.Transcript);
var path = Path.Combine(folder, fileName); var path = Path.Combine(folder, fileName);
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken); await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
logger.LogInformation("Created meeting transcript file {TranscriptPath}", path); logger.LogInformation("Created meeting transcript file {TranscriptPath}", path);
+8 -1
View File
@@ -110,7 +110,7 @@ Meeting Assistant SHALL allow a new recording to start after the previous run's
Each run SHALL keep its own transcript session, meeting note path, artifact paths, options, audio buffer, live transcript buffer, and speaker mappings isolated from later runs. Each run SHALL keep its own transcript session, meeting note path, artifact paths, options, audio buffer, live transcript buffer, and speaker mappings isolated from later runs.
Rapidly-created meeting note, transcript, assistant context, and summary artifact filenames SHALL be distinct. Generated meeting note, assistant context, transcript, and summary artifact filenames SHALL use the meeting start time with minute precision in the form `yyyyMMdd-HHmm-{type}.md`, where `type` is one of `note`, `context`, `transcript`, or `summary`.
#### Scenario: Hotkey stops recording with buffered audio #### Scenario: Hotkey stops recording with buffered audio
- **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription - **WHEN** the user presses the hotkey to stop recording while captured audio is still buffered for transcription
@@ -123,6 +123,13 @@ Rapidly-created meeting note, transcript, assistant context, and summary artifac
- **AND** final transcription and summary output from the stopped run use the stopped run's files - **AND** final transcription and summary output from the stopped run use the stopped run's files
- **AND** live transcription buffers from the stopped run are not written to the new run - **AND** live transcription buffers from the stopped run are not written to the new run
#### Scenario: Generated artifact names use minute precision
- **WHEN** a recording starts at `2026-05-30T09:30:30`
- **THEN** Meeting Assistant uses `20260530-0930-note.md` for the meeting note filename
- **AND** uses `20260530-0930-transcript.md` for the transcript filename
- **AND** uses `20260530-0930-context.md` for the assistant context filename
- **AND** uses `20260530-0930-summary.md` for the summary filename
### Requirement: Transcripts are written to the configured vault folder ### Requirement: Transcripts are written to the configured vault folder
Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder. Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder.