Files
meeting-assistant/MeetingAssistant/Summary/MeetingSummaryTools.cs
T
codex 7777b349a5
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
Archive summary refinements and profile switching
2026-05-27 23:11:21 +02:00

824 lines
29 KiB
C#

using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using System.Diagnostics;
using System.Text.Json;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
public sealed class MeetingSummaryTools
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
private readonly IDictationWordStore? dictationWordStore;
private readonly SummaryAgentWriteAudit? writeAudit;
private readonly BoundMeetingProjectResolver projectResolver;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions(), null)
{
}
public MeetingSummaryTools(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
IDictationWordStore? dictationWordStore = null,
SummaryAgentWriteAudit? writeAudit = null)
{
this.artifacts = artifacts;
this.options = options;
this.dictationWordStore = dictationWordStore;
this.writeAudit = writeAudit;
projectResolver = new BoundMeetingProjectResolver(options);
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.TranscriptPath, @from, to);
}
public Task<string> ReadMeetingNote(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.MeetingNotePath, @from, to);
}
public Task<string> ReadContext(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(artifacts.AssistantContextPath, @from, to);
}
public async Task<string> ReadUserNotes(int? @from = null, int? to = null)
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return "";
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
var body = document.HasFrontmatter ? document.Body.Trim() : content;
return ReadLines(body, @from, to);
}
public Task<string> ReadGlossary(int? @from = null, int? to = null)
{
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
}
public async Task<string> AddDictationWord(string word)
{
if (dictationWordStore is null)
{
return "Dictation word store is not available.";
}
if (string.IsNullOrWhiteSpace(word))
{
return "Refused: word must not be empty.";
}
if (writeAudit is not null)
{
IReadOnlyList<string>? capturedWords = null;
await writeAudit.CaptureFileWriteAsync(
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
}
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
}
public async Task<string> AddAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var displayName = attendee.Trim();
var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
if (note.Frontmatter.Attendees.Any(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase)))
{
return $"Attendee {normalized} already exists.";
}
note.Frontmatter.Attendees.Add(displayName);
await WriteMeetingNoteAsync(note);
return $"Added attendee {displayName}.";
}
public async Task<string> RemoveAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var normalized = MeetingAttendeeNames.NormalizeDisplayName(attendee);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
var removed = note.Frontmatter.Attendees.RemoveAll(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return $"Attendee {normalized} was not present.";
}
await WriteMeetingNoteAsync(note);
return $"Removed attendee {normalized}.";
}
public async Task<string> OverrideSpeaker(string speaker, string replacement, bool merge = false)
{
if (string.IsNullOrWhiteSpace(speaker) || string.IsNullOrWhiteSpace(replacement))
{
return "Refused: speaker and replacement must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var speakerOverride = new SpeakerOverride(speaker.Trim(), replacement.Trim(), merge);
if (string.Equals(speakerOverride.From, speakerOverride.To, StringComparison.OrdinalIgnoreCase))
{
return "Refused: speaker and replacement must be different.";
}
if (!merge && await SpeakerOverrideArtifacts.TranscriptContainsSpeakerAsync(
artifacts.TranscriptPath,
speakerOverride.To,
CancellationToken.None))
{
return $"Refused: transcript already contains speaker {speakerOverride.To}. Call override_speaker with merge=true only when you are certain both speakers are the same identity.";
}
await SpeakerOverrideArtifacts.ApplyToTranscriptAsync(artifacts.TranscriptPath, speakerOverride);
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(artifacts.AssistantContextPath, speakerOverride);
return merge
? $"Merged speaker {speakerOverride.From} into {speakerOverride.To}."
: $"Overrode speaker {speakerOverride.From} with {speakerOverride.To}.";
}
public async Task<string> DeleteIdentity(string identity)
{
if (string.IsNullOrWhiteSpace(identity))
{
return "Refused: identity must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
artifacts.TranscriptPath,
identity,
CancellationToken.None);
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
artifacts.AssistantContextPath,
deletion,
CancellationToken.None);
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
}
public async Task<string> WriteSummary(string markdown, string? title = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
var meetingNote = await ReadMeetingNoteAsync();
var summaryTitle = string.IsNullOrWhiteSpace(meetingNote.Frontmatter.Title)
? title
: meetingNote.Frontmatter.Title;
var frontmatter = await MeetingSummaryFrontmatterFactory.CreateAsync(
artifacts,
meetingNote,
string.IsNullOrWhiteSpace(summaryTitle) ? "Meeting Summary" : summaryTitle,
CancellationToken.None);
await File.WriteAllTextAsync(
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
SummaryWasWritten = true;
return artifacts.SummaryPath;
}
public bool SummaryWasWritten { get; private set; }
public async Task<string> WriteContext(
string content,
int? @from = null,
int? to = null,
int? insert = null)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter)
? updatedBody
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
await File.WriteAllTextAsync(artifacts.AssistantContextPath, updated);
return artifacts.AssistantContextPath;
}
private static string StripAccidentalFrontmatter(string content)
{
var document = MarkdownDocumentParser.SplitOptional(content);
return document.HasFrontmatter ? document.Body : content;
}
public async Task<string> ListProjects()
{
var projects = await GetBoundProjectsAsync();
return string.Join('\n', projects.Select(project => project.Name));
}
public async Task<string> ListProjectFiles(string project)
{
var projectRoot = await ResolveBoundProjectRootAsync(project);
if (projectRoot is null)
{
return "";
}
var files = Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories)
.Select(path => ToToolPath(Path.GetRelativePath(projectRoot, path)))
.Order(StringComparer.Ordinal);
return string.Join('\n', files);
}
public async Task<string> ReadProjectFile(string project, string path, int? @from = null, int? to = null)
{
var filePath = await ResolveBoundProjectFilePathAsync(project, path);
if (filePath is null || !File.Exists(filePath))
{
return "";
}
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
}
public async Task<string> WriteProjectFile(
string project,
string path,
string content,
int? @from = null,
int? to = null,
int? insert = null)
{
var editMode = FileLineEditMode.Create(@from, to, insert);
if (editMode is null)
{
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
}
var target = ResolveExistingProjectFilePath(project, path);
if (target is null)
{
return "Refused: project does not exist or the path escapes the project folder.";
}
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
if (writeAudit is not null)
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => WriteProjectFileContentAsync(target.Path, content, editMode));
}
else
{
await WriteProjectFileContentAsync(target.Path, content, editMode);
}
return $"{target.Project.Name}/{ToToolPath(path)}";
}
public async Task<string> Search(string keywords, string[]? projects = null)
{
if (string.IsNullOrWhiteSpace(keywords))
{
return "";
}
var selectedProjects = await GetSearchProjectsAsync(projects);
if (selectedProjects.Count == 0)
{
return "";
}
var projectsRoot = GetProjectsRoot();
var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords);
return result is not null
? FormatRipgrepJson(result, selectedProjects.Count == 1)
: SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1);
}
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
{
if (!File.Exists(path))
{
return "";
}
var content = await File.ReadAllTextAsync(path);
return ReadLines(content, from, to);
}
private async Task<MeetingNote> ReadMeetingNoteAsync()
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return new MeetingNote("", new MeetingNoteFrontmatter(), "");
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
var frontmatter = new MeetingNoteFrontmatter();
if (document.HasFrontmatter)
{
var note = YamlDeserializer.Deserialize<MeetingNoteFrontmatterYaml>(document.Frontmatter) ?? new MeetingNoteFrontmatterYaml();
frontmatter.Title = note.Title ?? "";
frontmatter.StartTime = ParseDateTime(note.StartTime);
frontmatter.EndTime = ParseDateTime(note.EndTime);
frontmatter.Attendees = note.Attendees ?? [];
frontmatter.Projects = note.Projects ?? [];
frontmatter.Transcript = note.Transcript ?? "";
frontmatter.AssistantContext = note.AssistantContext ?? "";
frontmatter.Summary = note.Summary ?? "";
}
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
}
private async Task WriteMeetingNoteAsync(MeetingNote note)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
RenderMeetingNote(note.Frontmatter, note.UserNotes));
}
private static string RenderMeetingNote(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 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 string EscapeListItem(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "\"\"";
}
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
? EscapeQuoted(value)
: value;
}
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();
if (projects is null || projects.Length == 0)
{
return boundProjects;
}
var requested = projects
.Where(project => !string.IsNullOrWhiteSpace(project))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return boundProjects
.Where(project => requested.Contains(project.Name))
.ToList();
}
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
{
if (string.IsNullOrWhiteSpace(project))
{
return null;
}
return (await GetBoundProjectsAsync())
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
}
private async Task<string?> ResolveBoundProjectRootAsync(string project)
{
return (await ResolveBoundProjectAsync(project))?.Path;
}
private async Task<string?> ResolveBoundProjectFilePathAsync(string project, string path)
{
var projectRoot = await ResolveBoundProjectRootAsync(project);
if (projectRoot is null || string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return null;
}
var fullPath = Path.GetFullPath(Path.Combine(projectRoot, path));
return IsWithinDirectory(projectRoot, fullPath) ? fullPath : null;
}
private ProjectFileTarget? ResolveExistingProjectFilePath(string project, string path)
{
if (string.IsNullOrWhiteSpace(project) ||
string.IsNullOrWhiteSpace(path) ||
Path.IsPathRooted(path))
{
return null;
}
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return null;
}
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
if (projectFolder is null)
{
return null;
}
var fullPath = Path.GetFullPath(Path.Combine(projectFolder.Path, path));
return IsWithinDirectory(projectFolder.Path, fullPath)
? new ProjectFileTarget(projectFolder, fullPath)
: null;
}
private static async Task WriteProjectFileContentAsync(
string filePath,
string content,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
await File.WriteAllTextAsync(filePath, content);
return;
}
var lines = File.Exists(filePath)
? (await File.ReadAllLinesAsync(filePath)).ToList()
: [];
var replacementLines = SplitLines(content);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
return;
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
await File.WriteAllTextAsync(filePath, string.Join('\n', lines));
}
private static List<string> SplitLines(string content)
{
if (content.Length == 0)
{
return [];
}
return content
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n')
.ToList();
}
private static string ReadLines(string content, int? from = null, int? to = null)
{
if (!from.HasValue && !to.HasValue)
{
return content;
}
var lines = SplitLines(content);
if (lines.Count == 0)
{
return "";
}
var start = Math.Clamp((from ?? 1) - 1, 0, lines.Count - 1);
var end = Math.Clamp((to ?? lines.Count) - 1, 0, lines.Count - 1);
if (end < start)
{
return "";
}
return string.Join('\n', lines.GetRange(start, end - start + 1));
}
private static string ApplyLineEdit(
string existingContent,
string replacementContent,
FileLineEditMode editMode)
{
if (editMode.Kind == FileLineEditKind.Overwrite)
{
return replacementContent;
}
var lines = SplitLines(existingContent);
var replacementLines = SplitLines(replacementContent);
if (editMode.Kind == FileLineEditKind.Insert)
{
var insertIndex = Math.Clamp(editMode.Insert!.Value - 1, 0, lines.Count);
lines.InsertRange(insertIndex, replacementLines);
return string.Join('\n', lines);
}
var startIndex = Math.Clamp(editMode.From!.Value - 1, 0, lines.Count);
var endIndexExclusive = Math.Clamp(editMode.To!.Value, 0, lines.Count);
if (endIndexExclusive < startIndex)
{
endIndexExclusive = startIndex;
}
lines.RemoveRange(startIndex, endIndexExclusive - startIndex);
lines.InsertRange(startIndex, replacementLines);
return string.Join('\n', lines);
}
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
return projectResolver.GetBoundProjectsAsync(artifacts);
}
private string GetProjectsRoot()
{
return projectResolver.ProjectsRoot;
}
private static async Task<string?> RunRipgrepAsync(
string projectsRoot,
IReadOnlyList<BoundMeetingProject> projects,
string keywords)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "rg",
WorkingDirectory = projectsRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("--json");
startInfo.ArgumentList.Add("--line-number");
startInfo.ArgumentList.Add("--color");
startInfo.ArgumentList.Add("never");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(keywords);
foreach (var project in projects)
{
startInfo.ArgumentList.Add(project.Name);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode is 0 or 1 ? output : null;
}
catch
{
return null;
}
}
private static string FormatRipgrepJson(string output, bool singleProject)
{
var matches = new List<string>();
using var reader = new StringReader(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var type) ||
type.GetString() != "match" ||
!root.TryGetProperty("data", out var data))
{
continue;
}
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
var lineNumber = data.GetProperty("line_number").GetInt32();
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
matches.Add($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
{
try
{
var regex = new System.Text.RegularExpressions.Regex(keywords);
var matches = new List<string>();
foreach (var project in projects)
{
foreach (var file in Directory.EnumerateFiles(project.Path, "*", SearchOption.AllDirectories))
{
var lines = File.ReadAllLines(file);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
var relative = Path.Combine(project.Name, Path.GetRelativePath(project.Path, file));
matches.Add($"{FormatSearchPath(relative, singleProject)}:{index + 1} {lines[index]}");
}
}
}
}
return string.Join('\n', matches);
}
catch
{
return "";
}
}
private static string FormatSearchPath(string path, bool singleProject)
{
var formatted = ToToolPath(path);
if (!singleProject)
{
return formatted;
}
var slashIndex = formatted.IndexOf('/', StringComparison.Ordinal);
return slashIndex < 0 ? formatted : formatted[(slashIndex + 1)..];
}
private static bool IsWithinDirectory(string directory, string path)
{
var normalizedDirectory = Path.GetFullPath(directory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
}
private static string ToToolPath(string path)
{
return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal)
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
private sealed record FileLineEditMode(
FileLineEditKind Kind,
int? From = null,
int? To = null,
int? Insert = null)
{
public static FileLineEditMode? Create(int? from, int? to, int? insert)
{
if (insert.HasValue)
{
return from.HasValue || to.HasValue
? null
: new FileLineEditMode(FileLineEditKind.Insert, Insert: insert);
}
if (from.HasValue || to.HasValue)
{
return from.HasValue && to.HasValue && from.Value <= to.Value
? new FileLineEditMode(FileLineEditKind.Replace, From: from, To: to)
: null;
}
return new FileLineEditMode(FileLineEditKind.Overwrite);
}
}
private enum FileLineEditKind
{
Overwrite,
Replace,
Insert
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "end_time")]
public string? EndTime { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "attendees")]
public List<string>? Attendees { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "summary")]
public string? Summary { get; set; }
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed)
? parsed
: null;
}
}