using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using MeetingAssistant.Speakers; using Microsoft.EntityFrameworkCore; using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace MeetingAssistant.Workflow; public sealed class WorkflowRulesEditorTools { private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .IgnoreUnmatchedProperties() .Build(); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private readonly string? rulesPath; private readonly SpeakerIdentificationOptions speakerOptions; private readonly IDbContextFactory? dbContextFactory; private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue; public WorkflowRulesEditorTools( MeetingAssistantOptions options, IDbContextFactory? dbContextFactory = null, IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) { rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath); speakerOptions = options.SpeakerIdentification; this.dbContextFactory = dbContextFactory; this.samplePlaybackQueue = samplePlaybackQueue; } public async Task ReadRules(int? from = null, int? to = null) { if (rulesPath is null) { return "Workflow rules file is not configured."; } if (!File.Exists(rulesPath)) { return ""; } return ReadLines(await File.ReadAllTextAsync(rulesPath), from, to); } public async Task WriteRules(string yaml) { if (rulesPath is null) { return "Refused: workflow rules file is not configured."; } if (yaml is null) { return "Refused: yaml must not be null."; } var validation = ValidateYaml(yaml); if (validation is not null) { return validation; } Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!); await File.WriteAllTextAsync(rulesPath, yaml); return rulesPath; } public async Task Search(string keywords) { if (rulesPath is null || string.IsNullOrWhiteSpace(keywords) || !File.Exists(rulesPath)) { return ""; } var rgResult = await RunRipgrepAsync(rulesPath, keywords); return rgResult is not null ? FormatRipgrepJson(rgResult) : SearchWithRegexFallback(rulesPath, keywords); } public async Task SearchIdentities(string? query = null, int limit = 25) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var identities = await LoadIdentities(context) .OrderBy(identity => identity.CanonicalName ?? "") .ThenBy(identity => identity.Id) .ToListAsync(); if (!string.IsNullOrWhiteSpace(query)) { var needle = query.Trim(); identities = identities .Where(identity => IdentityMatches(identity, needle)) .ToList(); } return ToJson(identities .Take(Math.Clamp(limit, 1, 100)) .Select(ToIdentitySummary) .ToList()); } } public async Task ReadIdentity(int identityId) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var identity = await LoadIdentities(context) .SingleOrDefaultAsync(identity => identity.Id == identityId); return identity is null ? $"Identity {identityId} was not found." : ToJson(ToIdentityDetail(identity)); } } public async Task CreateIdentity( string? canonicalName = null, string[]? aliases = null, string[]? candidateNames = null) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var now = DateTimeOffset.UtcNow; var identity = new SpeakerIdentity { CanonicalName = NormalizeNullable(canonicalName), CreatedAt = now, UpdatedAt = now, Aliases = NormalizeNames(aliases) .Select(name => new SpeakerAlias { Name = name }) .ToList(), CandidateNames = NormalizeNames(candidateNames) .Select(name => new SpeakerCandidateName { Name = name }) .ToList() }; context.SpeakerIdentities.Add(identity); await context.SaveChangesAsync(); return ToJson(ToIdentityDetail(identity)); } } public async Task UpdateIdentity( int identityId, string? canonicalName = null, string[]? aliases = null, string[]? candidateNames = null) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var identity = await LoadIdentities(context) .SingleOrDefaultAsync(identity => identity.Id == identityId); if (identity is null) { return $"Identity {identityId} was not found."; } identity.CanonicalName = NormalizeNullable(canonicalName); identity.Aliases.Clear(); identity.Aliases.AddRange(NormalizeNames(aliases) .Select(name => new SpeakerAlias { Name = name })); identity.CandidateNames.Clear(); identity.CandidateNames.AddRange(NormalizeNames(candidateNames) .Select(name => new SpeakerCandidateName { Name = name })); identity.UpdatedAt = DateTimeOffset.UtcNow; await context.SaveChangesAsync(); return ToJson(ToIdentityDetail(identity)); } } public async Task DeleteIdentity(int identityId) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var identity = await LoadIdentities(context) .SingleOrDefaultAsync(identity => identity.Id == identityId); if (identity is null) { return $"Identity {identityId} was not found."; } context.SpeakerIdentities.Remove(identity); await context.SaveChangesAsync(); return $"Deleted identity {identityId}."; } } public async Task MergeIdentities(int targetIdentityId, int sourceIdentityId) { if (targetIdentityId == sourceIdentityId) { return "Refused: target and source identity are the same."; } var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var target = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == targetIdentityId); var source = await LoadIdentities(context).SingleOrDefaultAsync(identity => identity.Id == sourceIdentityId); if (target is null || source is null) { return $"Could not find target {targetIdentityId} or source {sourceIdentityId}."; } SpeakerIdentityMerger.MergeInto( target, source, speakerOptions.MaxSnippetsPerSpeaker); context.SpeakerIdentities.Remove(source); await context.SaveChangesAsync(); return ToJson(ToIdentityDetail(target)); } } public async Task ListIdentitySamples(int identityId) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var samples = await context.SpeakerSnippets .Where(sample => sample.SpeakerIdentityId == identityId) .ToListAsync(); return ToJson(samples .OrderBy(sample => sample.CreatedAt) .Select(ToIdentitySampleSummary) .ToList()); } } public async Task ReadIdentitySample(int sampleId) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); return sample is null ? $"Sample {sampleId} was not found." : ToJson(new IdentitySampleDetail( sample.Id, sample.SpeakerIdentityId, sample.CreatedAt, sample.WavBytes.Length, Convert.ToBase64String(sample.WavBytes))); } } public async Task DeleteIdentitySample(int sampleId) { var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); if (sample is null) { return $"Sample {sampleId} was not found."; } context.SpeakerSnippets.Remove(sample); await context.SaveChangesAsync(); return $"Deleted sample {sampleId}."; } } public async Task QueuePlayIdentitySample(int sampleId) { if (samplePlaybackQueue is null) { return "Sample playback queue is not configured."; } var context = await CreatePreparedIdentityContextAsync(CancellationToken.None); if (context is null) { return "Speaker identity database is not configured."; } await using (context) { var sample = await context.SpeakerSnippets.SingleOrDefaultAsync(sample => sample.Id == sampleId); return sample is null ? $"Sample {sampleId} was not found." : await samplePlaybackQueue.QueueAsync(sample.Id, sample.WavBytes); } } private static string? ValidateYaml(string yaml) { if (string.IsNullOrWhiteSpace(yaml)) { return null; } try { _ = YamlDeserializer.Deserialize(yaml); return null; } catch (YamlException exception) { return $"Refused: workflow rules YAML is invalid. {exception.Message}"; } } private static async Task RunRipgrepAsync(string rulesPath, string keywords) { try { var startInfo = new ProcessStartInfo { FileName = "rg", WorkingDirectory = Path.GetDirectoryName(rulesPath)!, 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); startInfo.ArgumentList.Add(Path.GetFileName(rulesPath)); 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) { var matches = new List(); 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($"{ToToolPath(path)}:{lineNumber} {text.TrimEnd('\r', '\n')}"); } return string.Join('\n', matches); } private static string SearchWithRegexFallback(string rulesPath, string keywords) { try { var regex = new Regex(keywords, RegexOptions.IgnoreCase); var matches = new List(); var lines = File.ReadAllLines(rulesPath); for (var index = 0; index < lines.Length; index++) { if (regex.IsMatch(lines[index])) { matches.Add($"{Path.GetFileName(rulesPath)}:{index + 1} {lines[index]}"); } } return string.Join('\n', matches); } catch { return ""; } } private static string ReadLines(string content, int? from = null, int? to = null) { if (!from.HasValue && !to.HasValue) { return content; } var lines = content .Replace("\r\n", "\n", StringComparison.Ordinal) .Replace('\r', '\n') .Split('\n') .ToList(); 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 ToToolPath(string path) { return path.Replace(Path.DirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal) .Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal); } private async Task CreateIdentityContextAsync(CancellationToken cancellationToken) { return dbContextFactory is null ? null : await dbContextFactory.CreateDbContextAsync(cancellationToken); } private async Task CreatePreparedIdentityContextAsync(CancellationToken cancellationToken) { var context = await CreateIdentityContextAsync(cancellationToken); if (context is null) { return null; } await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken); return context; } private static IQueryable LoadIdentities(SpeakerIdentityDbContext context) { return context.SpeakerIdentities .Include(identity => identity.Aliases) .Include(identity => identity.CandidateNames) .Include(identity => identity.Snippets) .Include(identity => identity.References); } private static bool IdentityMatches(SpeakerIdentity identity, string query) { return new[] { identity.CanonicalName } .Concat(identity.Aliases.Select(alias => alias.Name)) .Concat(identity.CandidateNames.Select(candidate => candidate.Name)) .Where(value => !string.IsNullOrWhiteSpace(value)) .Any(value => value!.Contains(query, StringComparison.OrdinalIgnoreCase)); } private static IdentitySummary ToIdentitySummary(SpeakerIdentity identity) { return new IdentitySummary( identity.Id, identity.CanonicalName, identity.GetDisplayName(), identity.Aliases.Select(alias => alias.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(), identity.CandidateNames.Select(candidate => candidate.Name).Order(StringComparer.OrdinalIgnoreCase).ToArray(), identity.Snippets.Count, identity.References.Count, identity.UpdatedAt); } private static IdentityDetail ToIdentityDetail(SpeakerIdentity identity) { return new IdentityDetail( ToIdentitySummary(identity), identity.References .OrderByDescending(reference => reference.CreatedAt) .Select(reference => new IdentityReferenceDetail( reference.Id, reference.MeetingNotePath, reference.TranscriptPath, reference.CreatedAt)) .ToArray(), identity.Snippets .OrderBy(sample => sample.CreatedAt) .Select(ToIdentitySampleSummary) .ToArray()); } private static IdentitySampleSummary ToIdentitySampleSummary(SpeakerSnippet sample) { return new IdentitySampleSummary( sample.Id, sample.SpeakerIdentityId, sample.CreatedAt, sample.WavBytes.Length); } private static IReadOnlyList NormalizeNames(IEnumerable? names) { return names? .Select(name => name.Trim()) .Where(name => !string.IsNullOrWhiteSpace(name)) .Distinct(StringComparer.OrdinalIgnoreCase) .Order(StringComparer.OrdinalIgnoreCase) .ToArray() ?? []; } private static string? NormalizeNullable(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } private static string ToJson(T value) { return JsonSerializer.Serialize(value, JsonOptions); } private sealed record IdentitySummary( int Id, string? CanonicalName, string? DisplayName, IReadOnlyList Aliases, IReadOnlyList CandidateNames, int SampleCount, int ReferenceCount, DateTimeOffset UpdatedAt); private sealed record IdentityDetail( IdentitySummary Identity, IReadOnlyList References, IReadOnlyList Samples); private sealed record IdentityReferenceDetail( int Id, string MeetingNotePath, string TranscriptPath, DateTimeOffset CreatedAt); private sealed record IdentitySampleSummary( int Id, int IdentityId, DateTimeOffset CreatedAt, int ByteCount); private sealed record IdentitySampleDetail( int Id, int IdentityId, DateTimeOffset CreatedAt, int ByteCount, string Base64Wav); }