Public Access
Implement meeting assistant v1
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public interface IMeetingArtifactStore
|
||||
{
|
||||
Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public enum AssistantContextState
|
||||
{
|
||||
Transcribing,
|
||||
SpeakerRecognition,
|
||||
Summarizing,
|
||||
Finished,
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public interface IMeetingMetadataProvider
|
||||
{
|
||||
Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MeetingMetadata(
|
||||
string Title,
|
||||
IReadOnlyList<string> Attendees,
|
||||
string Agenda,
|
||||
DateTimeOffset? ScheduledEnd = null);
|
||||
|
||||
public sealed class NoopMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<MeetingMetadata?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public interface IMeetingNoteOpener
|
||||
{
|
||||
Task OpenAsync(string notePath, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public interface IMeetingNoteStore
|
||||
{
|
||||
Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken);
|
||||
|
||||
Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
internal readonly record struct MarkdownDocument(
|
||||
bool HasFrontmatter,
|
||||
string Frontmatter,
|
||||
string Body);
|
||||
|
||||
internal static class MarkdownDocumentParser
|
||||
{
|
||||
public static MarkdownDocument SplitOptional(string content)
|
||||
{
|
||||
using var reader = new StringReader(content);
|
||||
if (reader.ReadLine() != "---")
|
||||
{
|
||||
return new MarkdownDocument(false, "", content);
|
||||
}
|
||||
|
||||
var yaml = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (line == "---")
|
||||
{
|
||||
return new MarkdownDocument(
|
||||
true,
|
||||
string.Join(Environment.NewLine, yaml),
|
||||
RemoveBodySeparator(reader.ReadToEnd()));
|
||||
}
|
||||
|
||||
yaml.Add(line);
|
||||
}
|
||||
|
||||
return new MarkdownDocument(false, "", content);
|
||||
}
|
||||
|
||||
public static MarkdownDocument SplitRequired(
|
||||
string content,
|
||||
string missingFrontmatterMessage,
|
||||
string unclosedFrontmatterMessage)
|
||||
{
|
||||
using var reader = new StringReader(content);
|
||||
if (reader.ReadLine() != "---")
|
||||
{
|
||||
throw new InvalidDataException(missingFrontmatterMessage);
|
||||
}
|
||||
|
||||
var yaml = new List<string>();
|
||||
string? line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
if (line == "---")
|
||||
{
|
||||
return new MarkdownDocument(
|
||||
true,
|
||||
string.Join(Environment.NewLine, yaml),
|
||||
RemoveBodySeparator(reader.ReadToEnd()));
|
||||
}
|
||||
|
||||
yaml.Add(line);
|
||||
}
|
||||
|
||||
throw new InvalidDataException(unclosedFrontmatterMessage);
|
||||
}
|
||||
|
||||
private static string RemoveBodySeparator(string body)
|
||||
{
|
||||
if (body.StartsWith("\r\n", StringComparison.Ordinal))
|
||||
{
|
||||
return body[2..];
|
||||
}
|
||||
|
||||
return body.StartsWith('\n')
|
||||
? body[1..]
|
||||
: body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
public sealed class MarkdownMeetingArtifactStore : IMeetingArtifactStore
|
||||
{
|
||||
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly ILogger<MarkdownMeetingArtifactStore> logger;
|
||||
|
||||
public MarkdownMeetingArtifactStore(ILogger<MarkdownMeetingArtifactStore> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task CreateAssistantContextAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
string agenda,
|
||||
DateTimeOffset? scheduledEnd,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
|
||||
|
||||
var content = Render(
|
||||
artifacts,
|
||||
AssistantContextState.Transcribing,
|
||||
agenda,
|
||||
scheduledEnd,
|
||||
"# Assistant Context" + Environment.NewLine + Environment.NewLine + "## Live Context" + Environment.NewLine);
|
||||
|
||||
await File.WriteAllTextAsync(artifacts.AssistantContextPath, content, cancellationToken);
|
||||
logger.LogInformation("Created assistant context note {AssistantContextPath}", artifacts.AssistantContextPath);
|
||||
}
|
||||
|
||||
public async Task UpdateAssistantContextStateAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
AssistantContextState state,
|
||||
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 body = existing.Body;
|
||||
var existingFrontmatter = ReadFrontmatter(existing.Frontmatter);
|
||||
var agenda = existingFrontmatter.Agenda ?? "";
|
||||
var scheduledEnd = ParseDateTime(existingFrontmatter.ScheduledEnd);
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
artifacts.AssistantContextPath,
|
||||
Render(artifacts, state, agenda, scheduledEnd, body),
|
||||
cancellationToken);
|
||||
logger.LogInformation(
|
||||
"Updated assistant context note {AssistantContextPath} state to {State}",
|
||||
artifacts.AssistantContextPath,
|
||||
ToYamlState(state));
|
||||
}
|
||||
|
||||
private static string Render(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
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
|
||||
};
|
||||
|
||||
return MeetingArtifactFrontmatterRenderer.Render(
|
||||
frontmatter,
|
||||
body);
|
||||
}
|
||||
|
||||
private static AssistantContextFrontmatterYaml ReadFrontmatter(string frontmatter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(frontmatter))
|
||||
{
|
||||
return new AssistantContextFrontmatterYaml();
|
||||
}
|
||||
|
||||
return YamlDeserializer.Deserialize<AssistantContextFrontmatterYaml>(frontmatter)
|
||||
?? new AssistantContextFrontmatterYaml();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDateTime(string? value)
|
||||
{
|
||||
return DateTimeOffset.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private sealed class AssistantContextFrontmatterYaml
|
||||
{
|
||||
[YamlMember(Alias = "agenda")]
|
||||
public string? Agenda { get; set; }
|
||||
|
||||
[YamlMember(Alias = "scheduled_end")]
|
||||
public string? ScheduledEnd { get; set; }
|
||||
}
|
||||
|
||||
private static string ToYamlState(AssistantContextState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AssistantContextState.Transcribing => "transcribing",
|
||||
AssistantContextState.SpeakerRecognition => "speaker recognition",
|
||||
AssistantContextState.Summarizing => "summarizing",
|
||||
AssistantContextState.Finished => "finished",
|
||||
AssistantContextState.Error => "error",
|
||||
_ => "error"
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using YamlDotNet.Core;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class MarkdownMeetingNoteStore : IMeetingNoteStore
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILogger<MarkdownMeetingNoteStore> logger;
|
||||
private readonly IDeserializer yamlDeserializer;
|
||||
|
||||
public MarkdownMeetingNoteStore(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILogger<MarkdownMeetingNoteStore> logger)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.logger = logger;
|
||||
yamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task<MeetingNote> SaveAsync(MeetingNote note, CancellationToken cancellationToken)
|
||||
{
|
||||
var folder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(note.Path)
|
||||
? Path.Combine(folder, $"{DateTimeOffset.Now:yyyyMMdd-HHmmss}-{Slugify(note.Frontmatter.Title)}.md")
|
||||
: note.Path;
|
||||
var frontmatter = PrepareFrontmatter(note.Frontmatter, path);
|
||||
var content = Render(frontmatter, note.UserNotes);
|
||||
|
||||
await File.WriteAllTextAsync(path, content, cancellationToken);
|
||||
logger.LogInformation("Saved meeting note {MeetingNotePath}", path);
|
||||
|
||||
return new MeetingNote(path, frontmatter, note.UserNotes);
|
||||
}
|
||||
|
||||
public async Task<MeetingNote> ReadAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitRequired(
|
||||
content,
|
||||
"Meeting note does not start with YAML frontmatter.",
|
||||
"Meeting note frontmatter is not closed.");
|
||||
var yaml = yamlDeserializer.Deserialize<MeetingNoteYaml>(document.Frontmatter) ?? new MeetingNoteYaml();
|
||||
var frontmatter = new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = yaml.Title ?? "",
|
||||
StartTime = ParseDateTime(yaml.StartTime),
|
||||
EndTime = ParseDateTime(yaml.EndTime),
|
||||
Attendees = yaml.Attendees ?? [],
|
||||
Projects = yaml.Projects ?? [],
|
||||
Transcript = yaml.Transcript ?? "",
|
||||
AssistantContext = yaml.AssistantContext ?? "",
|
||||
Summary = yaml.Summary ?? ""
|
||||
};
|
||||
|
||||
return new MeetingNote(path, frontmatter, document.Body);
|
||||
}
|
||||
|
||||
private MeetingNoteFrontmatter PrepareFrontmatter(MeetingNoteFrontmatter frontmatter, string notePath)
|
||||
{
|
||||
return new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = frontmatter.Title,
|
||||
StartTime = frontmatter.StartTime,
|
||||
EndTime = frontmatter.EndTime,
|
||||
Attendees = frontmatter.Attendees,
|
||||
Projects = frontmatter.Projects,
|
||||
Transcript = ObsidianLink.ToWikiLink(frontmatter.Transcript, notePath, "Transcript"),
|
||||
AssistantContext = ObsidianLink.ToWikiLink(frontmatter.AssistantContext, notePath, "Assistant Context"),
|
||||
Summary = ObsidianLink.ToWikiLink(frontmatter.Summary, notePath, "Summary")
|
||||
};
|
||||
}
|
||||
|
||||
private static string Render(MeetingNoteFrontmatter frontmatter, string userNotes)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"---",
|
||||
$"title: {EscapeScalar(frontmatter.Title)}",
|
||||
$"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}",
|
||||
$"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}",
|
||||
"attendees:"
|
||||
};
|
||||
|
||||
lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}"));
|
||||
lines.Add("projects:");
|
||||
lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}"));
|
||||
lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}");
|
||||
lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}");
|
||||
lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}");
|
||||
lines.Add("---");
|
||||
lines.Add("");
|
||||
lines.Add(userNotes);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private static string EscapeQuoted(string value)
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
private static string EscapeNullableDateTime(DateTimeOffset? value)
|
||||
{
|
||||
return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ParseDateTime(string? value)
|
||||
{
|
||||
return DateTimeOffset.TryParse(value, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string EscapeListItem(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal))
|
||||
{
|
||||
return EscapeQuoted(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class MeetingNoteYaml
|
||||
{
|
||||
[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 = "attendees")]
|
||||
public List<string>? Attendees { get; set; }
|
||||
|
||||
[YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
|
||||
[YamlMember(Alias = "transcript")]
|
||||
public string? Transcript { get; set; }
|
||||
|
||||
[YamlMember(Alias = "assistant_context")]
|
||||
public string? AssistantContext { get; set; }
|
||||
|
||||
[YamlMember(Alias = "summary")]
|
||||
public string? Summary { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class MeetingArtifactFrontmatter
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
|
||||
public string Meeting { get; set; } = "";
|
||||
|
||||
public string Transcript { get; set; } = "";
|
||||
|
||||
public string AssistantContext { get; set; } = "";
|
||||
|
||||
public string Summary { get; set; } = "";
|
||||
|
||||
public string? State { get; set; }
|
||||
|
||||
public string? Agenda { get; set; }
|
||||
|
||||
public DateTimeOffset? ScheduledEnd { get; set; }
|
||||
}
|
||||
|
||||
public static class MeetingArtifactFrontmatterRenderer
|
||||
{
|
||||
public static string Render(
|
||||
MeetingArtifactFrontmatter frontmatter,
|
||||
string body)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("---");
|
||||
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);
|
||||
if (!string.IsNullOrWhiteSpace(frontmatter.State))
|
||||
{
|
||||
AppendScalar(builder, "state", frontmatter.State);
|
||||
}
|
||||
|
||||
if (frontmatter.Agenda is not null)
|
||||
{
|
||||
AppendBlockScalar(builder, "agenda", frontmatter.Agenda);
|
||||
}
|
||||
|
||||
if (frontmatter.ScheduledEnd is not null)
|
||||
{
|
||||
AppendDateTime(builder, "scheduled_end", frontmatter.ScheduledEnd);
|
||||
}
|
||||
|
||||
builder.AppendLine("---");
|
||||
builder.AppendLine();
|
||||
builder.Append(body.TrimStart('\r', '\n'));
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static (string Frontmatter, string Body) Split(string content)
|
||||
{
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
return (document.Frontmatter, document.Body);
|
||||
}
|
||||
|
||||
public static MeetingArtifactFrontmatter Create(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingNote meetingNote,
|
||||
string title,
|
||||
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"),
|
||||
State = state
|
||||
};
|
||||
}
|
||||
|
||||
public static string DefaultTitle(MeetingNote meetingNote, string fallback)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
|
||||
? fallback
|
||||
: meetingNote.Frontmatter.Title;
|
||||
}
|
||||
|
||||
private static void AppendScalar(StringBuilder builder, string key, string? value)
|
||||
{
|
||||
builder.Append(key);
|
||||
builder.Append(": ");
|
||||
builder.AppendLine(string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value));
|
||||
}
|
||||
|
||||
private static void AppendQuoted(StringBuilder builder, string key, string value)
|
||||
{
|
||||
builder.Append(key);
|
||||
builder.Append(": ");
|
||||
builder.AppendLine(EscapeQuoted(value));
|
||||
}
|
||||
|
||||
private static void AppendDateTime(StringBuilder builder, string key, DateTimeOffset? value)
|
||||
{
|
||||
builder.Append(key);
|
||||
builder.Append(": ");
|
||||
builder.AppendLine(value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O")));
|
||||
}
|
||||
|
||||
private static void AppendBlockScalar(StringBuilder builder, string key, string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
builder.Append(key);
|
||||
builder.AppendLine(": \"\"");
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Append(key);
|
||||
builder.AppendLine(": |-");
|
||||
foreach (var line in value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'))
|
||||
{
|
||||
builder.Append(" ");
|
||||
builder.AppendLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
private static string EscapeQuoted(string value)
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
|
||||
}
|
||||
|
||||
private static string EscapeListItem(string value)
|
||||
{
|
||||
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
|
||||
? EscapeQuoted(value)
|
||||
: value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed record MeetingNote(
|
||||
string Path,
|
||||
MeetingNoteFrontmatter Frontmatter,
|
||||
string UserNotes);
|
||||
|
||||
public sealed class MeetingNoteFrontmatter
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
public DateTimeOffset? StartTime { get; set; }
|
||||
|
||||
public DateTimeOffset? EndTime { get; set; }
|
||||
|
||||
public List<string> Attendees { get; set; } = [];
|
||||
|
||||
public List<string> Projects { get; set; } = [];
|
||||
|
||||
public string Transcript { get; set; } = "";
|
||||
|
||||
public string AssistantContext { get; set; } = "";
|
||||
|
||||
public string Summary { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public static class MeetingNoteActionLinks
|
||||
{
|
||||
public static string CreateSummaryRetryLink(
|
||||
string publicBaseUrl,
|
||||
string summaryPath,
|
||||
string label = "Retry summary generation")
|
||||
{
|
||||
var baseUrl = string.IsNullOrWhiteSpace(publicBaseUrl)
|
||||
? "http://localhost:5090"
|
||||
: publicBaseUrl.TrimEnd('/');
|
||||
var summaryFileName = Path.GetFileName(summaryPath);
|
||||
var url = $"{baseUrl}/meetings/summary/retry?summaryPath={Uri.EscapeDataString(summaryFileName)}";
|
||||
return $"[{label}]({url})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public static class MeetingNoteTemplate
|
||||
{
|
||||
public static MeetingNote Create(
|
||||
string title,
|
||||
DateTimeOffset? startTime = null,
|
||||
DateTimeOffset? endTime = null,
|
||||
IEnumerable<string>? attendees = null,
|
||||
IEnumerable<string>? projects = null,
|
||||
string transcriptPath = "",
|
||||
string assistantContextPath = "",
|
||||
string summaryPath = "",
|
||||
string userNotes = "")
|
||||
{
|
||||
var notePath = "";
|
||||
var frontmatter = new MeetingNoteFrontmatter
|
||||
{
|
||||
Title = title,
|
||||
StartTime = startTime,
|
||||
EndTime = endTime,
|
||||
Attendees = attendees?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? [],
|
||||
Projects = projects?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? [],
|
||||
Transcript = transcriptPath,
|
||||
AssistantContext = assistantContextPath,
|
||||
Summary = summaryPath
|
||||
};
|
||||
|
||||
return new MeetingNote(notePath, frontmatter, userNotes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed record MeetingSessionArtifacts(
|
||||
string MeetingNotePath,
|
||||
string TranscriptPath,
|
||||
string AssistantContextPath,
|
||||
string SummaryPath);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public static class ObsidianLink
|
||||
{
|
||||
public static string ToWikiLink(string pathOrLink, string sourceNotePath, string label)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pathOrLink) || pathOrLink.StartsWith("[[", StringComparison.Ordinal))
|
||||
{
|
||||
return pathOrLink;
|
||||
}
|
||||
|
||||
var sourceFolder = Path.GetDirectoryName(sourceNotePath) ?? "";
|
||||
var relativePath = Path.GetRelativePath(sourceFolder, pathOrLink);
|
||||
var withoutExtension = Path.Combine(
|
||||
Path.GetDirectoryName(relativePath) ?? "",
|
||||
Path.GetFileNameWithoutExtension(relativePath))
|
||||
.Replace(Path.DirectorySeparatorChar, '/')
|
||||
.Replace(Path.AltDirectorySeparatorChar, '/');
|
||||
|
||||
return $"[[{withoutExtension}|{label}]]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class ObsidianMeetingNoteOpener : IMeetingNoteOpener
|
||||
{
|
||||
private readonly ILogger<ObsidianMeetingNoteOpener> logger;
|
||||
|
||||
public ObsidianMeetingNoteOpener(ILogger<ObsidianMeetingNoteOpener> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task OpenAsync(string notePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var uri = $"obsidian://open?path={Uri.EscapeDataString(Path.GetFullPath(notePath))}";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = uri,
|
||||
UseShellExecute = true
|
||||
});
|
||||
logger.LogInformation("Opened meeting note in Obsidian via {ObsidianUri}", uri);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
public sealed class OutlookClassicMeetingMetadataProvider : IMeetingMetadataProvider
|
||||
{
|
||||
private const int OutlookCalendarFolder = 9;
|
||||
private readonly ILogger<OutlookClassicMeetingMetadataProvider> logger;
|
||||
|
||||
public OutlookClassicMeetingMetadataProvider(ILogger<OutlookClassicMeetingMetadataProvider> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public Task<MeetingMetadata?> GetCurrentMeetingAsync(
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
return Task.FromResult(GetCurrentMeeting(startedAt));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
logger.LogDebug(exception, "Outlook Classic meeting metadata was not available");
|
||||
return Task.FromResult<MeetingMetadata?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
private MeetingMetadata? GetCurrentMeeting(DateTimeOffset startedAt)
|
||||
{
|
||||
object? application = null;
|
||||
object? session = null;
|
||||
object? calendar = null;
|
||||
object? items = null;
|
||||
object? restrictedItems = null;
|
||||
|
||||
try
|
||||
{
|
||||
var applicationType = Type.GetTypeFromProgID("Outlook.Application");
|
||||
if (applicationType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
application = Activator.CreateInstance(applicationType);
|
||||
if (application is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
session = GetProperty(application, "Session");
|
||||
calendar = Invoke(session!, "GetDefaultFolder", OutlookCalendarFolder);
|
||||
items = GetProperty(calendar!, "Items");
|
||||
SetProperty(items!, "IncludeRecurrences", true);
|
||||
Invoke(items!, "Sort", "[Start]");
|
||||
|
||||
var startedLocal = startedAt.LocalDateTime;
|
||||
var windowStart = startedLocal.AddMinutes(-1);
|
||||
var windowEnd = startedLocal.AddMinutes(5);
|
||||
var filter =
|
||||
$"[Start] <= '{windowEnd:g}' AND [End] >= '{windowStart:g}'";
|
||||
restrictedItems = Invoke(items!, "Restrict", filter);
|
||||
|
||||
var candidates = new List<OutlookAppointmentCandidate>();
|
||||
var count = Convert.ToInt32(GetProperty(restrictedItems!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
var appointment = Invoke(restrictedItems!, "Item", index);
|
||||
if (appointment is not null && IsTeamsAppointment(appointment))
|
||||
{
|
||||
candidates.Add(new OutlookAppointmentCandidate(
|
||||
appointment,
|
||||
Convert.ToDateTime(GetProperty(appointment, "Start")),
|
||||
Convert.ToDateTime(GetProperty(appointment, "End"))));
|
||||
}
|
||||
else
|
||||
{
|
||||
ReleaseComObject(appointment);
|
||||
}
|
||||
}
|
||||
|
||||
var selected = OutlookMeetingCandidateSelector.Select(
|
||||
candidates,
|
||||
startedLocal,
|
||||
candidate => candidate.Start,
|
||||
candidate => candidate.End);
|
||||
if (selected is null)
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
ReleaseComObject(candidate.Appointment);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var appointment = selected.Appointment;
|
||||
var title = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
|
||||
var attendees = ReadAttendees(appointment);
|
||||
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
|
||||
return new MeetingMetadata(
|
||||
title.Trim(),
|
||||
attendees,
|
||||
ExtractAgenda(body),
|
||||
ToLocalOffset(selected.End));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
ReleaseComObject(candidate.Appointment);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(restrictedItems);
|
||||
ReleaseComObject(items);
|
||||
ReleaseComObject(calendar);
|
||||
ReleaseComObject(session);
|
||||
ReleaseComObject(application);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ExtractAgenda(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var lines = body.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n');
|
||||
var agendaLines = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (IsTeamsSeparator(line) || IsTeamsJoinLine(line))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
agendaLines.Add(line);
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, agendaLines).Trim();
|
||||
}
|
||||
|
||||
private static bool IsTeamsAppointment(object appointment)
|
||||
{
|
||||
var subject = Convert.ToString(GetProperty(appointment, "Subject")) ?? "";
|
||||
var location = Convert.ToString(GetProperty(appointment, "Location")) ?? "";
|
||||
var body = Convert.ToString(GetProperty(appointment, "Body")) ?? "";
|
||||
return ContainsTeamsMarker(subject) ||
|
||||
ContainsTeamsMarker(location) ||
|
||||
ContainsTeamsMarker(body);
|
||||
}
|
||||
|
||||
private static bool ContainsTeamsMarker(string value)
|
||||
{
|
||||
return value.Contains("teams.microsoft.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Contains("Microsoft Teams", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Contains("Join the meeting now", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Contains("Join Microsoft Teams Meeting", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsTeamsSeparator(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
return trimmed.Length >= 8 &&
|
||||
trimmed.All(character => character is '_' or '-' or '*' or ' ');
|
||||
}
|
||||
|
||||
private static bool IsTeamsJoinLine(string line)
|
||||
{
|
||||
return ContainsTeamsMarker(line);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadAttendees(object appointment)
|
||||
{
|
||||
var attendees = new List<string>();
|
||||
var organizer = Convert.ToString(GetProperty(appointment, "Organizer"));
|
||||
if (!string.IsNullOrWhiteSpace(organizer))
|
||||
{
|
||||
attendees.Add(organizer.Trim());
|
||||
}
|
||||
|
||||
object? recipients = null;
|
||||
try
|
||||
{
|
||||
recipients = GetProperty(appointment, "Recipients");
|
||||
var count = Convert.ToInt32(GetProperty(recipients!, "Count"));
|
||||
for (var index = 1; index <= count; index++)
|
||||
{
|
||||
object? recipient = null;
|
||||
try
|
||||
{
|
||||
recipient = Invoke(recipients!, "Item", index);
|
||||
var formatted = FormatRecipient(recipient!);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
{
|
||||
attendees.Add(formatted);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(recipients);
|
||||
}
|
||||
|
||||
return attendees
|
||||
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToLocalOffset(DateTime value)
|
||||
{
|
||||
return new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Local));
|
||||
}
|
||||
|
||||
private static string FormatRecipient(object recipient)
|
||||
{
|
||||
var name = Convert.ToString(GetProperty(recipient, "Name"))?.Trim() ?? "";
|
||||
var email = Convert.ToString(GetProperty(recipient, "Address"))?.Trim() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return email;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) ||
|
||||
email.Contains('/'))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
return $"{name} <{email}>";
|
||||
}
|
||||
|
||||
private static object? GetProperty(object target, string name)
|
||||
{
|
||||
return target.GetType().InvokeMember(
|
||||
name,
|
||||
System.Reflection.BindingFlags.GetProperty,
|
||||
null,
|
||||
target,
|
||||
null);
|
||||
}
|
||||
|
||||
private static void SetProperty(object target, string name, object value)
|
||||
{
|
||||
target.GetType().InvokeMember(
|
||||
name,
|
||||
System.Reflection.BindingFlags.SetProperty,
|
||||
null,
|
||||
target,
|
||||
[value]);
|
||||
}
|
||||
|
||||
private static object? Invoke(object target, string name, params object[] arguments)
|
||||
{
|
||||
return target.GetType().InvokeMember(
|
||||
name,
|
||||
System.Reflection.BindingFlags.InvokeMethod,
|
||||
null,
|
||||
target,
|
||||
arguments);
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object? value)
|
||||
{
|
||||
if (value is not null && Marshal.IsComObject(value))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OutlookAppointmentCandidate(
|
||||
object Appointment,
|
||||
DateTime Start,
|
||||
DateTime End);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace MeetingAssistant.MeetingNotes;
|
||||
|
||||
internal static class OutlookMeetingCandidateSelector
|
||||
{
|
||||
private static readonly TimeSpan MinimumRemainingOverlap = TimeSpan.FromMinutes(5);
|
||||
private static readonly TimeSpan UpcomingStartWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static T? Select<T>(
|
||||
IReadOnlyList<T> candidates,
|
||||
DateTime startedAt,
|
||||
Func<T, DateTime> getStart,
|
||||
Func<T, DateTime> getEnd)
|
||||
where T : class
|
||||
{
|
||||
var goodOverlaps = candidates
|
||||
.Where(candidate =>
|
||||
getStart(candidate) <= startedAt &&
|
||||
getEnd(candidate) >= startedAt.Add(MinimumRemainingOverlap))
|
||||
.ToList();
|
||||
if (goodOverlaps.Count == 1)
|
||||
{
|
||||
return goodOverlaps[0];
|
||||
}
|
||||
|
||||
if (goodOverlaps.Count > 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var upcoming = candidates
|
||||
.Where(candidate =>
|
||||
getStart(candidate) > startedAt &&
|
||||
getStart(candidate) <= startedAt.Add(UpcomingStartWindow))
|
||||
.OrderBy(getStart)
|
||||
.ToList();
|
||||
return upcoming.Count == 1 ? upcoming[0] : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user