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);
}
[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]
public async Task FrontmatterUpdatePreservesExistingUserNotesBody()
{
@@ -186,6 +186,7 @@ public sealed class RecordingCoordinatorTests
var audioSource = new ControlledAudioSource();
var transcriptStore = new InMemoryTranscriptStore("C:\\Vault\\Meetings\\Transcripts\\20260519-transcript.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 artifactStore = new InMemoryMeetingArtifactStore();
var audioArchive = new InMemoryRecordedAudioStore();
@@ -206,22 +207,21 @@ public sealed class RecordingCoordinatorTests
SummariesFolder = "C:\\Vault\\Meetings\\Summaries"
}
}),
NullLogger<MeetingRecordingCoordinator>.Instance);
NullLogger<MeetingRecordingCoordinator>.Instance,
inactivityClock: clock);
var status = await coordinator.StartAsync(CancellationToken.None);
Assert.True(status.IsRecording);
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.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
Assert.EndsWith("-assistant-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext, StringComparison.Ordinal);
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\\Assistant Context\\20260519-1003-context.md", noteStore.SavedNote?.Frontmatter.AssistantContext);
Assert.Equal("C:\\Vault\\Meetings\\Summaries\\20260519-1003-summary.md", noteStore.SavedNote?.Frontmatter.Summary);
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\\Transcripts\\20260519-transcript.md", artifactStore.CreatedArtifacts?.TranscriptPath);
Assert.StartsWith("C:\\Vault\\Meetings\\Assistant Context\\", artifactStore.CreatedArtifacts?.AssistantContextPath, StringComparison.Ordinal);
Assert.StartsWith("C:\\Vault\\Meetings\\Summaries\\", artifactStore.CreatedArtifacts?.SummaryPath, StringComparison.Ordinal);
Assert.Equal("C:\\Vault\\Meetings\\Assistant Context\\20260519-1003-context.md", artifactStore.CreatedArtifacts?.AssistantContextPath);
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.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 englishRoot = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"), "english");
var launchProfiles = CreateLaunchProfiles(defaultRoot, englishRoot);
var clock = new ManualMeetingInactivityClock(DateTimeOffset.Parse("2026-05-19T10:03:42+02:00"));
var summaryPipeline = new CapturingMeetingSummaryPipeline();
var coordinator = new MeetingRecordingCoordinator(
new CapturedChunkThenCancelAudioSource(new AudioChunk([1, 0], 16000, 1)),
@@ -1113,15 +1114,16 @@ public sealed class RecordingCoordinatorTests
summaryPipeline,
Options.Create(launchProfiles.GetRequiredProfile(null).Options),
NullLogger<MeetingRecordingCoordinator>.Instance,
launchProfiles: launchProfiles);
launchProfiles: launchProfiles,
inactivityClock: clock);
var started = await coordinator.StartAsync("english", CancellationToken.None);
await coordinator.StopAsync(CancellationToken.None);
Assert.StartsWith(Path.Combine(englishRoot, "Transcripts"), started.TranscriptPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Notes"), started.MeetingNotePath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Context"), started.AssistantContextPath, StringComparison.OrdinalIgnoreCase);
Assert.StartsWith(Path.Combine(englishRoot, "Summaries"), started.SummaryPath, StringComparison.OrdinalIgnoreCase);
Assert.Equal(Path.Combine(englishRoot, "Transcripts", "20260519-1003-transcript.md"), started.TranscriptPath);
Assert.Equal(Path.Combine(englishRoot, "Notes", "20260519-1003-note.md"), started.MeetingNotePath);
Assert.Equal(Path.Combine(englishRoot, "Context", "20260519-1003-context.md"), started.AssistantContextPath);
Assert.Equal(Path.Combine(englishRoot, "Summaries", "20260519-1003-summary.md"), started.SummaryPath);
Assert.Equal("english-summary-model", summaryPipeline.Options?.Agent.Model);
}
@@ -2015,6 +2017,26 @@ public sealed class RecordingCoordinatorTests
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]
public async Task TemporaryRecordedAudioStoreCreatesConfiguredFolderAndWritesPcmWav()
{
@@ -2159,6 +2181,14 @@ public sealed class RecordingCoordinatorTests
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)
{
segments.Add(ParseTranscriptLine(line));
@@ -2226,6 +2256,14 @@ public sealed class RecordingCoordinatorTests
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)
{
AppendHistory.Add(new TranscriptWrite(session, ParseTranscriptLine(line)));
@@ -2887,10 +2925,13 @@ public sealed class RecordingCoordinatorTests
public Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
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)
@@ -2931,7 +2972,16 @@ public sealed class RecordingCoordinatorTests
CancellationToken cancellationToken)
{
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);
}
@@ -35,7 +35,7 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
Directory.CreateDirectory(folder);
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;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes);
@@ -108,16 +108,6 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
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)
{
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(
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return CreateSessionAsync(cancellationToken);
}
DateTimeOffset startedAt,
CancellationToken cancellationToken);
Task AppendAsync(TranscriptSession session, TranscriptionSegment segment, CancellationToken cancellationToken)
{
@@ -156,9 +156,9 @@ public sealed class MeetingRecordingCoordinator
var launchProfile = ResolveProfile(launchProfileName);
var runOptions = launchProfile.Options;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var startedAt = inactivityClock.Now;
currentSession = await transcriptStore.CreateSessionAsync(runOptions, startedAt, cancellationToken);
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
var assistantContextPath = GetAssistantContextPath(startedAt, runOptions);
var summaryPath = GetSummaryPath(startedAt, runOptions);
currentMeetingNote = await meetingNoteStore.SaveAsync(
@@ -1491,12 +1491,12 @@ public sealed class MeetingRecordingCoordinator
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)
{
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, "summary");
return GetMeetingArtifactPath(runOptions.Vault, runOptions.Vault.SummariesFolder, startedAt, MeetingArtifactFileNames.Summary);
}
private static string GetMeetingArtifactPath(
@@ -1506,7 +1506,7 @@ public sealed class MeetingRecordingCoordinator
string artifactName)
{
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)
@@ -17,17 +17,18 @@ public sealed class VaultTranscriptStore : ITranscriptStore
public async Task<TranscriptSession> CreateSessionAsync(CancellationToken cancellationToken)
{
return await CreateSessionAsync(options, cancellationToken);
return await CreateSessionAsync(options, DateTimeOffset.Now, cancellationToken);
}
public async Task<TranscriptSession> CreateSessionAsync(
MeetingAssistantOptions options,
DateTimeOffset startedAt,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.TranscriptsFolder);
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);
await File.WriteAllTextAsync(path, $"# Meeting Transcript{Environment.NewLine}{Environment.NewLine}", cancellationToken);
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.
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
- **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** 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
Meeting Assistant SHALL write live transcript text to a markdown file in the configured vault folder.