Public Access
635 lines
21 KiB
C#
635 lines
21 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, bool replace_file = false)
|
|
{
|
|
if (rulesPath is null)
|
|
{
|
|
return "Refused: workflow rules file is not configured.";
|
|
}
|
|
|
|
if (yaml is null)
|
|
{
|
|
return "Refused: yaml must not be null.";
|
|
}
|
|
|
|
var existing = File.Exists(rulesPath)
|
|
? await File.ReadAllTextAsync(rulesPath)
|
|
: "";
|
|
var updated = replace_file
|
|
? yaml
|
|
: AppendContent(existing, yaml);
|
|
var validation = ValidateYaml(updated);
|
|
if (validation is not null)
|
|
{
|
|
return validation;
|
|
}
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(rulesPath)!);
|
|
await File.WriteAllTextAsync(rulesPath, updated);
|
|
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 string AppendContent(string existingContent, string content)
|
|
{
|
|
if (string.IsNullOrEmpty(existingContent))
|
|
{
|
|
return content;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(content))
|
|
{
|
|
return existingContent;
|
|
}
|
|
|
|
var separator = existingContent.EndsWith('\n') || existingContent.EndsWith('\r')
|
|
? ""
|
|
: "\n";
|
|
return existingContent + separator + content;
|
|
}
|
|
|
|
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);
|
|
}
|