Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -4,16 +4,23 @@ public interface IMeetingArtifactStore
{
Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken);
Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken);
Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken);
Task UpdateAssistantContextStateAsync(
MeetingSessionArtifacts artifacts,
AssistantContextState state,
@@ -22,6 +29,7 @@ public interface IMeetingArtifactStore
public enum AssistantContextState
{
CollectingMetadata,
Transcribing,
SpeakerRecognition,
Summarizing,
@@ -4,5 +4,13 @@ public interface IMeetingNoteStore
{
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
Task<MeetingNote> SaveAsync(
MeetingNote note,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return SaveAsync(note, cancellationToken);
}
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
}
@@ -17,6 +17,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
public async Task CreateAssistantContextAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
@@ -25,7 +26,8 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
var content = Render(
artifacts,
AssistantContextState.Transcribing,
meetingNote,
AssistantContextState.CollectingMetadata,
agenda,
scheduledEnd,
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
@@ -45,12 +47,13 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
var body = existing.Body;
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
var metadata = existingFrontmatter.ToMeetingArtifactMetadata();
var agenda = existingFrontmatter.Agenda ?? "";
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
Render(artifacts, state, agenda, scheduledEnd, body),
Render(artifacts, metadata, state, agenda, scheduledEnd, body),
cancellationToken);
logger.LogInformation(
"Updated assistant context note {AssistantContextPath} state to {State}",
@@ -60,6 +63,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
public async Task UpdateAssistantContextMetadataAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
string agenda,
DateTimeOffset? scheduledEnd,
CancellationToken cancellationToken)
@@ -73,29 +77,68 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
Render(artifacts, state, agenda, scheduledEnd, existing.Body),
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
cancellationToken);
logger.LogInformation(
"Updated assistant context note {AssistantContextPath} calendar metadata",
artifacts.AssistantContextPath);
}
public async Task UpdateAssistantContextMeetingAsync(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? MarkdownDocumentParser.SplitOptional(await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken))
: new MarkdownDocument(false, "", "## Live Context" + Environment.NewLine);
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
var state = ParseState(existingFrontmatter.State);
var agenda = existingFrontmatter.Agenda ?? "";
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
await File.WriteAllTextAsync(
artifacts.AssistantContextPath,
Render(artifacts, meetingNote, state, agenda, scheduledEnd, existing.Body),
cancellationToken);
logger.LogInformation(
"Updated assistant context note {AssistantContextPath} meeting metadata",
artifacts.AssistantContextPath);
}
private static string Render(
MeetingSessionArtifacts artifacts,
MeetingNote meetingNote,
AssistantContextState state,
string? agenda,
DateTimeOffset? scheduledEnd,
string body)
{
var frontmatter = new MeetingArtifactFrontmatter
{
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, artifacts.AssistantContextPath, "Meeting Note"),
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, artifacts.AssistantContextPath, "Transcript"),
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, artifacts.AssistantContextPath, "Summary"),
State = ToYamlState(state),
Agenda = agenda ?? "",
ScheduledEnd = scheduledEnd
};
var metadata = new AssistantContextMeetingMetadata(
meetingNote.Frontmatter.Title,
meetingNote.Frontmatter.StartTime,
meetingNote.Frontmatter.EndTime);
return Render(artifacts, metadata, state, agenda, scheduledEnd, body);
}
private static string Render(
MeetingSessionArtifacts artifacts,
AssistantContextMeetingMetadata metadata,
AssistantContextState state,
string? agenda,
DateTimeOffset? scheduledEnd,
string body)
{
var frontmatter = MeetingArtifactFrontmatterRenderer.Create(
artifacts,
metadata.Title,
metadata.StartTime,
metadata.EndTime,
artifacts.AssistantContextPath,
ToYamlState(state));
frontmatter.Agenda = agenda ?? "";
frontmatter.ScheduledEnd = scheduledEnd;
return MeetingArtifactFrontmatterRenderer.Render(
frontmatter,
@@ -125,23 +168,46 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
[YamlMember(Alias = "state")]
public string? State { get; set; }
[YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlMember(Alias = "end_time")]
public string? EndTime { get; set; }
[YamlMember(Alias = "agenda")]
public string? Agenda { get; set; }
[YamlMember(Alias = "scheduled_end")]
public string? ScheduledEnd { get; set; }
public AssistantContextMeetingMetadata ToMeetingArtifactMetadata()
{
return new AssistantContextMeetingMetadata(
Title ?? "",
ParseDateTime(StartTime),
ParseDateTime(EndTime));
}
}
private sealed record AssistantContextMeetingMetadata(
string Title,
DateTimeOffset? StartTime,
DateTimeOffset? EndTime);
private static AssistantContextState ParseState(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"transcribing" => AssistantContextState.Transcribing,
"collecting metadata" => AssistantContextState.CollectingMetadata,
"speaker recognition" => AssistantContextState.SpeakerRecognition,
"summarizing" => AssistantContextState.Summarizing,
"finished" => AssistantContextState.Finished,
"error" => AssistantContextState.Error,
_ => AssistantContextState.Transcribing
_ => AssistantContextState.CollectingMetadata
};
}
@@ -149,6 +215,7 @@ public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
{
return state switch
{
AssistantContextState.CollectingMetadata => "collecting metadata",
AssistantContextState.Transcribing => "transcribing",
AssistantContextState.SpeakerRecognition => "speaker recognition",
AssistantContextState.Summarizing => "summarizing",
@@ -22,12 +22,20 @@ public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
}
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
{
return await SaveAsync(note, options, cancellationToken);
}
public async Task<MeetingNote> SaveAsync(
MeetingNote note,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var folder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
Directory.CreateDirectory(folder);
var path = string.IsNullOrWhiteSpace(note.Path)
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Slugify(note.Frontmatter.Title)}.md")
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss-fffffff}-{Slugify(note.Frontmatter.Title)}.md")
: note.Path;
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
var content = Render(frontmatter, note.UserNotes);
@@ -36,10 +36,10 @@ public static class MeetingArtifactFrontmatterRenderer
AppendScalar(builder, "title", frontmatter.Title);
AppendDateTime(builder, "start_time", frontmatter.StartTime);
AppendDateTime(builder, "end_time", frontmatter.EndTime);
AppendQuoted(builder, "meeting", frontmatter.Meeting);
AppendQuoted(builder, "transcript", frontmatter.Transcript);
AppendQuoted(builder, "assistant_context", frontmatter.AssistantContext);
AppendQuoted(builder, "summary", frontmatter.Summary);
AppendQuotedIfNotEmpty(builder, "meeting", frontmatter.Meeting);
AppendQuotedIfNotEmpty(builder, "transcript", frontmatter.Transcript);
AppendQuotedIfNotEmpty(builder, "assistant_context", frontmatter.AssistantContext);
AppendQuotedIfNotEmpty(builder, "summary", frontmatter.Summary);
if (!string.IsNullOrWhiteSpace(frontmatter.State))
{
AppendScalar(builder, "state", frontmatter.State);
@@ -73,16 +73,33 @@ public static class MeetingArtifactFrontmatterRenderer
string title,
string sourceNotePath,
string? state = null)
{
return Create(
artifacts,
title,
meetingNote.Frontmatter.StartTime,
meetingNote.Frontmatter.EndTime,
sourceNotePath,
state);
}
public static MeetingArtifactFrontmatter Create(
MeetingSessionArtifacts artifacts,
string title,
DateTimeOffset? startTime,
DateTimeOffset? endTime,
string sourceNotePath,
string? state = null)
{
return new MeetingArtifactFrontmatter
{
Title = title,
StartTime = meetingNote.Frontmatter.StartTime,
EndTime = meetingNote.Frontmatter.EndTime,
Meeting = ObsidianLink.ToWikiLink(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
Transcript = ObsidianLink.ToWikiLink(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
AssistantContext = ObsidianLink.ToWikiLink(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
Summary = ObsidianLink.ToWikiLink(artifacts.SummaryPath, sourceNotePath, "Summary"),
StartTime = startTime,
EndTime = endTime,
Meeting = LinkUnlessSelf(artifacts.MeetingNotePath, sourceNotePath, "Meeting Note"),
Transcript = LinkUnlessSelf(artifacts.TranscriptPath, sourceNotePath, "Transcript"),
AssistantContext = LinkUnlessSelf(artifacts.AssistantContextPath, sourceNotePath, "Assistant Context"),
Summary = LinkUnlessSelf(artifacts.SummaryPath, sourceNotePath, "Summary"),
State = state
};
}
@@ -108,6 +125,16 @@ public static class MeetingArtifactFrontmatterRenderer
builder.AppendLine(EscapeQuoted(value));
}
private static void AppendQuotedIfNotEmpty(StringBuilder builder, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
AppendQuoted(builder, key, value);
}
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
{
builder.Append(key);
@@ -145,4 +172,19 @@ public static class MeetingArtifactFrontmatterRenderer
: value;
}
private static string LinkUnlessSelf(string targetPath, string sourceNotePath, string label)
{
return IsSamePath(targetPath, sourceNotePath)
? ""
: ObsidianLink.ToWikiLink(targetPath, sourceNotePath, label);
}
private static bool IsSamePath(string first, string second)
{
return string.Equals(
Path.GetFullPath(first),
Path.GetFullPath(second),
StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,11 @@
namespace MeetingAssistant.MeetingNotes;
internal static class MeetingAttendeeNames
{
public static string NormalizeDisplayName(string attendee)
{
var trimmed = attendee.Trim();
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
}
}