Public Access
266 lines
9.9 KiB
C#
266 lines
9.9 KiB
C#
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<bool> 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<SpeakerIdentityDeletion> 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<IReadOnlyList<SpeakerOverride>> ReadFromAssistantContextAsync(
|
|
string assistantContextPath,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!File.Exists(assistantContextPath))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
|
var overrides = new List<SpeakerOverride>();
|
|
foreach (Match match in SpeakerOverrideMarkerRegex().Matches(content))
|
|
{
|
|
SpeakerOverrideJson? parsed;
|
|
try
|
|
{
|
|
parsed = JsonSerializer.Deserialize<SpeakerOverrideJson>(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<IReadOnlyList<SpeakerIdentityDeletion>> ReadIdentityDeletionsFromAssistantContextAsync(
|
|
string assistantContextPath,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!File.Exists(assistantContextPath))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
|
var deletions = new List<SpeakerIdentityDeletion>();
|
|
foreach (Match match in SpeakerIdentityDeletionMarkerRegex().Matches(content))
|
|
{
|
|
SpeakerIdentityDeletionJson? parsed;
|
|
try
|
|
{
|
|
parsed = JsonSerializer.Deserialize<SpeakerIdentityDeletionJson>(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 $"<!-- meeting-assistant:speaker-override {json} -->";
|
|
}
|
|
|
|
private static async Task<string> 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 $"<!-- meeting-assistant:speaker-identity-deletion {json} -->";
|
|
}
|
|
|
|
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(@"<!--\s*meeting-assistant:speaker-override\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
|
private static partial Regex SpeakerOverrideMarkerRegex();
|
|
|
|
[GeneratedRegex(@"<!--\s*meeting-assistant:speaker-identity-deletion\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
|
private static partial Regex SpeakerIdentityDeletionMarkerRegex();
|
|
|
|
[GeneratedRegex(@"^\[[^\]]+\]\s*Removed-(?<number>\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);
|
|
}
|