Public Access
Add tray rules and identities editor
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user