Files
meeting-assistant/MeetingAssistant/Workflow/WorkflowRulesEditorTools.cs
T
codex 693f52afee
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
Add tray rules and identities editor
2026-05-28 01:28:16 +02:00

611 lines
20 KiB
C#

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<SpeakerIdentityDbContext>? dbContextFactory;
private readonly IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue;
public WorkflowRulesEditorTools(
MeetingAssistantOptions options,
IDbContextFactory<SpeakerIdentityDbContext>? dbContextFactory = null,
IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null)
{
rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
speakerOptions = options.SpeakerIdentification;
this.dbContextFactory = dbContextFactory;
this.samplePlaybackQueue = samplePlaybackQueue;
}
public async Task<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<string> 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<MeetingWorkflowRulesFile>(yaml);
return null;
}
catch (YamlException exception)
{
return $"Refused: workflow rules YAML is invalid. {exception.Message}";
}
}
private static async Task<string?> 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<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($"{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<string>();
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<SpeakerIdentityDbContext?> CreateIdentityContextAsync(CancellationToken cancellationToken)
{
return dbContextFactory is null
? null
: await dbContextFactory.CreateDbContextAsync(cancellationToken);
}
private async Task<SpeakerIdentityDbContext?> CreatePreparedIdentityContextAsync(CancellationToken cancellationToken)
{
var context = await CreateIdentityContextAsync(cancellationToken);
if (context is null)
{
return null;
}
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
return context;
}
private static IQueryable<SpeakerIdentity> 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<string> NormalizeNames(IEnumerable<string>? 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>(T value)
{
return JsonSerializer.Serialize(value, JsonOptions);
}
private sealed record IdentitySummary(
int Id,
string? CanonicalName,
string? DisplayName,
IReadOnlyList<string> Aliases,
IReadOnlyList<string> CandidateNames,
int SampleCount,
int ReferenceCount,
DateTimeOffset UpdatedAt);
private sealed record IdentityDetail(
IdentitySummary Identity,
IReadOnlyList<IdentityReferenceDetail> References,
IReadOnlyList<IdentitySampleSummary> 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);
}