using MeetingAssistant.MeetingNotes; using System.Text.Json; using System.Text.RegularExpressions; namespace MeetingAssistant.Speakers; public sealed record SpeakerOverride(string From, string To, bool Merge = false); public sealed record SpeakerIdentityDeletion(string Identity, string Replacement); public static partial class SpeakerOverrideArtifacts { public static async Task ApplyToTranscriptAsync( string transcriptPath, SpeakerOverride speakerOverride, CancellationToken cancellationToken = default) { if (!File.Exists(transcriptPath)) { return; } await RewriteTranscriptSpeakerAsync( transcriptPath, speakerOverride.From, speakerOverride.To, cancellationToken); } public static async Task TranscriptContainsSpeakerAsync( string transcriptPath, string speaker, CancellationToken cancellationToken = default) { if (!File.Exists(transcriptPath)) { return false; } var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken); return TranscriptContainsSpeaker(content, speaker); } public static async Task AppendToAssistantContextAsync( string assistantContextPath, SpeakerOverride speakerOverride, CancellationToken cancellationToken = default) { await AppendMarkerAsync(assistantContextPath, FormatMarker(speakerOverride), cancellationToken); } public static async Task ApplyDeletionToTranscriptAsync( string transcriptPath, string identity, CancellationToken cancellationToken = default) { var trimmedIdentity = identity.Trim(); var replacement = "Removed-1"; if (!File.Exists(transcriptPath)) { return new SpeakerIdentityDeletion(trimmedIdentity, replacement); } var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken); replacement = NextRemovedSpeakerLabel(content); await RewriteTranscriptSpeakerAsync( transcriptPath, trimmedIdentity, replacement, content, cancellationToken); return new SpeakerIdentityDeletion(trimmedIdentity, replacement); } public static async Task AppendIdentityDeletionToAssistantContextAsync( string assistantContextPath, SpeakerIdentityDeletion deletion, CancellationToken cancellationToken = default) { await AppendMarkerAsync(assistantContextPath, FormatMarker(deletion), cancellationToken); } public static async Task> ReadFromAssistantContextAsync( string assistantContextPath, CancellationToken cancellationToken = default) { if (!File.Exists(assistantContextPath)) { return []; } var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken); var overrides = new List(); foreach (Match match in SpeakerOverrideMarkerRegex().Matches(content)) { SpeakerOverrideJson? parsed; try { parsed = JsonSerializer.Deserialize(match.Groups["json"].Value); } catch (JsonException) { continue; } if (!string.IsNullOrWhiteSpace(parsed?.From) && !string.IsNullOrWhiteSpace(parsed.To)) { overrides.Add(new SpeakerOverride(parsed.From.Trim(), parsed.To.Trim(), parsed.Merge)); } } return overrides .DistinctBy(speakerOverride => $"{speakerOverride.From}\n{speakerOverride.To}\n{speakerOverride.Merge}", StringComparer.OrdinalIgnoreCase) .ToList(); } public static async Task> ReadIdentityDeletionsFromAssistantContextAsync( string assistantContextPath, CancellationToken cancellationToken = default) { if (!File.Exists(assistantContextPath)) { return []; } var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken); var deletions = new List(); foreach (Match match in SpeakerIdentityDeletionMarkerRegex().Matches(content)) { SpeakerIdentityDeletionJson? parsed; try { parsed = JsonSerializer.Deserialize(match.Groups["json"].Value); } catch (JsonException) { continue; } if (!string.IsNullOrWhiteSpace(parsed?.Identity) && !string.IsNullOrWhiteSpace(parsed.Replacement)) { deletions.Add(new SpeakerIdentityDeletion(parsed.Identity.Trim(), parsed.Replacement.Trim())); } } return deletions .DistinctBy(deletion => $"{deletion.Identity}\n{deletion.Replacement}", StringComparer.OrdinalIgnoreCase) .ToList(); } private static string FormatMarker(SpeakerOverride speakerOverride) { var json = JsonSerializer.Serialize(new SpeakerOverrideJson( speakerOverride.From, speakerOverride.To, speakerOverride.Merge)); return $""; } private static async Task ReadExistingAssistantContextAsync( string assistantContextPath, CancellationToken cancellationToken) { if (!File.Exists(assistantContextPath)) { throw new FileNotFoundException( "Speaker identity markers can only be written to an existing assistant context note.", assistantContextPath); } return await File.ReadAllTextAsync(assistantContextPath, cancellationToken); } private static async Task RewriteTranscriptSpeakerAsync( string transcriptPath, string from, string to, CancellationToken cancellationToken) { var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken); await RewriteTranscriptSpeakerAsync(transcriptPath, from, to, content, cancellationToken); } private static async Task RewriteTranscriptSpeakerAsync( string transcriptPath, string from, string to, string content, CancellationToken cancellationToken) { var pattern = $@"^(\[[^\]]+\]\s*){Regex.Escape(from)}:"; var updated = Regex.Replace( content, pattern, match => $"{match.Groups[1].Value}{to}:", RegexOptions.Multiline); if (!string.Equals(content, updated, StringComparison.Ordinal)) { await File.WriteAllTextAsync(transcriptPath, updated, cancellationToken); } } private static async Task AppendMarkerAsync( string assistantContextPath, string marker, CancellationToken cancellationToken) { var content = await ReadExistingAssistantContextAsync(assistantContextPath, cancellationToken); if (content.Contains(marker, StringComparison.Ordinal)) { return; } var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content); var updatedBody = body.TrimEnd('\r', '\n') + (string.IsNullOrWhiteSpace(body) ? "" : Environment.NewLine + Environment.NewLine) + marker + Environment.NewLine; var updated = string.IsNullOrWhiteSpace(frontmatter) ? updatedBody : "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody; await File.WriteAllTextAsync(assistantContextPath, updated, cancellationToken); } private static bool TranscriptContainsSpeaker(string content, string speaker) { var pattern = $@"^\[[^\]]+\]\s*{Regex.Escape(speaker.Trim())}:"; return Regex.IsMatch(content, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline); } private static string FormatMarker(SpeakerIdentityDeletion deletion) { var json = JsonSerializer.Serialize(new SpeakerIdentityDeletionJson(deletion.Identity, deletion.Replacement)); return $""; } private static string NextRemovedSpeakerLabel(string content) { var next = RemovedSpeakerLabelRegex() .Matches(content) .Select(match => int.TryParse(match.Groups["number"].Value, out var number) ? number : 0) .DefaultIfEmpty(0) .Max() + 1; return $"Removed-{next}"; } [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.Singleline)] private static partial Regex SpeakerOverrideMarkerRegex(); [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.Singleline)] private static partial Regex SpeakerIdentityDeletionMarkerRegex(); [GeneratedRegex(@"^\[[^\]]+\]\s*Removed-(?\d+):", RegexOptions.IgnoreCase | RegexOptions.Multiline)] private static partial Regex RemovedSpeakerLabelRegex(); private sealed record SpeakerOverrideJson( [property: System.Text.Json.Serialization.JsonPropertyName("from")] string From, [property: System.Text.Json.Serialization.JsonPropertyName("to")] string To, [property: System.Text.Json.Serialization.JsonPropertyName("merge")] bool Merge = false); private sealed record SpeakerIdentityDeletionJson( [property: System.Text.Json.Serialization.JsonPropertyName("identity")] string Identity, [property: System.Text.Json.Serialization.JsonPropertyName("replacement")] string Replacement); }