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:
@@ -32,7 +32,12 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
|
||||
return [];
|
||||
}
|
||||
|
||||
var path = ResolvePath(options.Automation.RulesPath);
|
||||
var path = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath);
|
||||
if (path is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
logger.LogDebug("Meeting workflow rules file {RulesPath} does not exist", path);
|
||||
@@ -49,12 +54,4 @@ public sealed class FileMeetingWorkflowRulesProvider : IMeetingWorkflowRulesProv
|
||||
?? new MeetingWorkflowRulesFile();
|
||||
return rulesFile.Rules;
|
||||
}
|
||||
|
||||
private static string ResolvePath(string configuredPath)
|
||||
{
|
||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorWindowService
|
||||
{
|
||||
void Show();
|
||||
}
|
||||
|
||||
public sealed class NoopWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly ILogger<NoopWorkflowRulesEditorWindowService> logger;
|
||||
|
||||
public NoopWorkflowRulesEditorWindowService(ILogger<NoopWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is only available on Windows");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
#if WINDOWS
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class MewUiWorkflowRulesEditorWindowService : IWorkflowRulesEditorWindowService
|
||||
{
|
||||
private readonly IServiceProvider services;
|
||||
private readonly ILogger<MewUiWorkflowRulesEditorWindowService> logger;
|
||||
private readonly object sync = new();
|
||||
private bool isRunning;
|
||||
|
||||
public MewUiWorkflowRulesEditorWindowService(
|
||||
IServiceProvider services,
|
||||
ILogger<MewUiWorkflowRulesEditorWindowService> logger)
|
||||
{
|
||||
this.services = services;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (isRunning)
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI is already running");
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
}
|
||||
|
||||
logger.LogInformation("Starting workflow rules editor UI thread");
|
||||
var thread = new Thread(RunWindow)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "Meeting Assistant Rules Editor"
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private void RunWindow()
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Workflow rules editor UI thread started");
|
||||
var viewModel = services.GetRequiredService<WorkflowRulesEditorChatViewModel>();
|
||||
var window = new WorkflowRulesEditorMewWindow(viewModel);
|
||||
window.Run();
|
||||
logger.LogInformation("Workflow rules editor UI window closed");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Workflow rules editor UI failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WorkflowRulesEditorMewWindow
|
||||
{
|
||||
private readonly WorkflowRulesEditorChatViewModel viewModel;
|
||||
private readonly StackPanel conversationPanel = new();
|
||||
private readonly ScrollViewer conversationScroll = new();
|
||||
private readonly MultiLineTextBox input = new();
|
||||
private readonly Button sendButton = new();
|
||||
private readonly EventHandler changedHandler;
|
||||
|
||||
public WorkflowRulesEditorMewWindow(WorkflowRulesEditorChatViewModel viewModel)
|
||||
{
|
||||
this.viewModel = viewModel;
|
||||
changedHandler = (_, _) => RequestRender();
|
||||
this.viewModel.Changed += changedHandler;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
ConfigureTheme();
|
||||
|
||||
conversationPanel
|
||||
.Vertical()
|
||||
.Spacing(8);
|
||||
input
|
||||
.Height(92)
|
||||
.Wrap(true)
|
||||
.Placeholder("Ask to make a rule or to list identities")
|
||||
.OnTextChanged(text => viewModel.Draft = text)
|
||||
.OnKeyDown(args =>
|
||||
{
|
||||
if (args.Key == Key.Enter && !args.ShiftKey)
|
||||
{
|
||||
args.Handled = true;
|
||||
_ = SendAsync();
|
||||
}
|
||||
});
|
||||
sendButton
|
||||
.Content("Send")
|
||||
.Width(84)
|
||||
.OnClick(() => _ = SendAsync());
|
||||
|
||||
var window = new Window()
|
||||
.Title("Edit rules and identities")
|
||||
.Resizable(680, 760, 520, 460)
|
||||
.Padding(0)
|
||||
.OnLoaded(Render)
|
||||
.OnClosed(() => viewModel.Changed -= changedHandler)
|
||||
.Content(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Padding(12)
|
||||
.Spacing(10)
|
||||
.Children(
|
||||
new DockPanel()
|
||||
.LastChildFill()
|
||||
.Spacing(8)
|
||||
.DockBottom()
|
||||
.Children(
|
||||
sendButton.DockRight(),
|
||||
input),
|
||||
conversationScroll
|
||||
.AutoVerticalScroll()
|
||||
.NoHorizontalScroll()
|
||||
.Content(conversationPanel)));
|
||||
var icon = LoadWindowIcon();
|
||||
if (icon is not null)
|
||||
{
|
||||
window.Icon = icon;
|
||||
}
|
||||
|
||||
Application.Create()
|
||||
.UseTheme(ThemeVariant.Dark)
|
||||
.UseWin32()
|
||||
.UseDirect2D()
|
||||
.Run(window);
|
||||
}
|
||||
|
||||
private static IconSource? LoadWindowIcon()
|
||||
{
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "meeting-assistant.ico");
|
||||
return File.Exists(iconPath)
|
||||
? IconSource.FromFile(iconPath)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static void ConfigureTheme()
|
||||
{
|
||||
ThemeManager.DefaultLightSeed = ThemeSeed.DefaultLight with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(245, 245, 245)
|
||||
};
|
||||
ThemeManager.DefaultDarkSeed = ThemeSeed.DefaultDark with
|
||||
{
|
||||
ButtonFace = Color.FromRgb(42, 46, 54)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task SendAsync()
|
||||
{
|
||||
var sendTask = viewModel.SendAsync();
|
||||
input.Text = viewModel.Draft;
|
||||
await sendTask;
|
||||
}
|
||||
|
||||
private void RequestRender()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(Render);
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
var shouldAutoScroll = WorkflowRulesEditorScrollPolicy.ShouldAutoScroll(
|
||||
conversationScroll.VerticalOffset,
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight);
|
||||
|
||||
conversationPanel.Clear();
|
||||
|
||||
foreach (var message in viewModel.Messages)
|
||||
{
|
||||
conversationPanel.Add(CreateMessageCard(message));
|
||||
}
|
||||
|
||||
if (viewModel.IsThinking)
|
||||
{
|
||||
conversationPanel.Add(new TextBlock()
|
||||
.Text("Thinking...")
|
||||
.Foreground(Color.FromRgb(156, 166, 181))
|
||||
.Margin(4, 2, 4, 2));
|
||||
}
|
||||
|
||||
sendButton.IsEnabled = !viewModel.IsThinking;
|
||||
|
||||
if (shouldAutoScroll)
|
||||
{
|
||||
QueueScrollConversationToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueScrollConversationToBottom()
|
||||
{
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, () =>
|
||||
{
|
||||
ScrollConversationToBottom();
|
||||
dispatcher.BeginInvoke(DispatcherPriority.Idle, ScrollConversationToBottom);
|
||||
});
|
||||
}
|
||||
|
||||
private void ScrollConversationToBottom()
|
||||
{
|
||||
conversationScroll.SetScrollOffsets(
|
||||
conversationScroll.HorizontalOffset,
|
||||
WorkflowRulesEditorScrollPolicy.GetBottomOffset(
|
||||
conversationScroll.ViewportHeight,
|
||||
conversationPanel.ActualHeight));
|
||||
}
|
||||
|
||||
private static Element CreateMessageCard(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
var isUser = message.Role == WorkflowRulesEditorChatRole.User;
|
||||
return new Border()
|
||||
.Padding(10)
|
||||
.Margin(isUser ? new Thickness(42, 0, 4, 0) : new Thickness(0, 0, 42, 0))
|
||||
.CornerRadius(8)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(isUser ? Color.FromRgb(68, 95, 132) : Color.FromRgb(69, 75, 86))
|
||||
.Background(isUser ? Color.FromRgb(26, 48, 78) : Color.FromRgb(35, 39, 46))
|
||||
.Child(WorkflowRulesEditorMarkdownRenderer.CreateContent(message.Content));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public enum WorkflowRulesEditorChatRole
|
||||
{
|
||||
User,
|
||||
Agent
|
||||
}
|
||||
|
||||
public sealed record WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole Role,
|
||||
string Content);
|
||||
|
||||
public sealed record WorkflowRulesEditorChatResult(
|
||||
string Response,
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> Conversation);
|
||||
|
||||
public interface IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using MeetingAssistant.Summary;
|
||||
using MeetingAssistant.Speakers;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
#pragma warning disable MAAI001
|
||||
public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPipeline
|
||||
{
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILoggerFactory loggerFactory;
|
||||
private readonly ILogger<WorkflowRulesEditorChatPipeline> logger;
|
||||
private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder;
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory;
|
||||
private readonly IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue;
|
||||
|
||||
public WorkflowRulesEditorChatPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<WorkflowRulesEditorChatPipeline> logger,
|
||||
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
|
||||
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
|
||||
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
this.logger = logger;
|
||||
this.instructionBuilder = instructionBuilder;
|
||||
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
|
||||
this.samplePlaybackQueue = samplePlaybackQueue;
|
||||
}
|
||||
|
||||
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
||||
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
||||
string userMessage,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
return new WorkflowRulesEditorChatResult("", conversation);
|
||||
}
|
||||
|
||||
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
var key = ResolveApiKey(agentOptions, "workflow rules editor");
|
||||
var tools = new WorkflowRulesEditorTools(
|
||||
options,
|
||||
speakerIdentityDbContextFactory,
|
||||
samplePlaybackQueue);
|
||||
var messages = conversation
|
||||
.Select(ToChatMessage)
|
||||
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
||||
.ToList();
|
||||
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
||||
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions: null,
|
||||
logger,
|
||||
firstRequestIsUser: false);
|
||||
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
||||
using var chatClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
agentOptions.Model,
|
||||
agentOptions.EnableThinking,
|
||||
ToReasoningEffortValue(agentOptions.ReasoningEffort),
|
||||
agentOptions.ReconnectionAttempts,
|
||||
agentOptions.ReconnectionDelay,
|
||||
compactionOptions,
|
||||
logger,
|
||||
firstRequestIsUser: true);
|
||||
var functionClient = chatClient
|
||||
.AsBuilder()
|
||||
.UseFunctionInvocation(loggerFactory)
|
||||
.Build();
|
||||
|
||||
var response = await functionClient.GetResponseAsync(
|
||||
messages,
|
||||
CreateChatOptions(agentOptions, tools, instructions),
|
||||
cancellationToken);
|
||||
var responseText = string.IsNullOrWhiteSpace(response.Text)
|
||||
? "(No response text returned.)"
|
||||
: response.Text.Trim();
|
||||
var nextConversation = conversation
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage.Trim()))
|
||||
.Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, responseText))
|
||||
.ToList();
|
||||
return new WorkflowRulesEditorChatResult(responseText, nextConversation);
|
||||
}
|
||||
|
||||
private static ChatMessage ToChatMessage(WorkflowRulesEditorChatMessage message)
|
||||
{
|
||||
return new ChatMessage(
|
||||
message.Role == WorkflowRulesEditorChatRole.Agent ? ChatRole.Assistant : ChatRole.User,
|
||||
message.Content);
|
||||
}
|
||||
|
||||
private static ChatOptions CreateChatOptions(
|
||||
AgentOptions options,
|
||||
WorkflowRulesEditorTools tools,
|
||||
string instructions)
|
||||
{
|
||||
return new ChatOptions
|
||||
{
|
||||
ModelId = options.Model,
|
||||
Instructions = instructions,
|
||||
MaxOutputTokens = options.MaxOutputTokens,
|
||||
AllowMultipleToolCalls = true,
|
||||
ToolMode = ChatToolMode.Auto,
|
||||
Tools =
|
||||
[
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadRules,
|
||||
"read_rules",
|
||||
"Read the configured workflow rules YAML file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.WriteRules,
|
||||
"write_rules",
|
||||
"Overwrite the configured workflow rules YAML file after validating that it parses as a workflow rules document."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.Search,
|
||||
"search",
|
||||
"Search the configured workflow rules YAML file using ripgrep-style syntax. The search is scoped to this single file."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.SearchIdentities,
|
||||
"search_identities",
|
||||
"Search or list speaker identities. Optional query matches canonical names, aliases, and candidate names. Returns JSON summaries."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadIdentity,
|
||||
"read_identity",
|
||||
"Read one speaker identity by numeric identity id, including aliases, candidate names, references, and sample metadata."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.CreateIdentity,
|
||||
"create_identity",
|
||||
"Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.UpdateIdentity,
|
||||
"update_identity",
|
||||
"Replace a speaker identity's canonicalName, aliases, and candidateNames by numeric identity id. Omitted aliases/candidateNames become empty."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DeleteIdentity,
|
||||
"delete_identity",
|
||||
"Delete a speaker identity and all linked aliases, candidate names, references, and samples by numeric identity id."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.MergeIdentities,
|
||||
"merge_identities",
|
||||
"Merge sourceIdentityId into targetIdentityId, preserving target canonical name, adding source names as aliases, moving references and samples, then deleting the source."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ListIdentitySamples,
|
||||
"list_identity_samples",
|
||||
"List audio samples for a speaker identity by numeric identity id. Returns sample ids and metadata, not audio bytes."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.ReadIdentitySample,
|
||||
"read_identity_sample",
|
||||
"Read one audio sample by numeric sample id. Returns metadata and base64 WAV bytes."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.DeleteIdentitySample,
|
||||
"delete_identity_sample",
|
||||
"Delete one audio sample by numeric sample id."),
|
||||
AIFunctionFactory.Create(
|
||||
tools.QueuePlayIdentitySample,
|
||||
"queue_play_identity_sample",
|
||||
"Queue one audio sample by numeric sample id for local playback. This is asynchronous and does not block until playback finishes.")
|
||||
],
|
||||
Reasoning = options.EnableThinking
|
||||
? new ReasoningOptions { Effort = ToReasoningEffort(options.ReasoningEffort) }
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
private static LiteLlmResponsesCompactionOptions CreateCompactionOptions(
|
||||
AgentOptions options,
|
||||
IChatClient? summaryClient)
|
||||
{
|
||||
return new LiteLlmResponsesCompactionOptions
|
||||
{
|
||||
Enabled = options.EnableCompaction,
|
||||
ContextWindowTokens = options.ContextWindowTokens,
|
||||
MaxOutputTokens = options.MaxOutputTokens,
|
||||
RemainingRatio = options.CompactionRemainingRatio,
|
||||
CompactPath = options.ResponsesCompactPath,
|
||||
FallbackStrategy = OpenAiMeetingSummaryAgentPipeline.CreateFallbackCompactionStrategyForTests(
|
||||
options.ContextWindowTokens,
|
||||
options.MaxOutputTokens,
|
||||
options.CompactionRemainingRatio,
|
||||
summaryClient)
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveApiKey(AgentOptions options, string agentName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Key))
|
||||
{
|
||||
return options.Key;
|
||||
}
|
||||
|
||||
var value = Environment.GetEnvironmentVariable(options.KeyEnv);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"No {agentName} API key configured. Set MeetingAssistant:WorkflowRulesEditor:Key, MeetingAssistant:Agent:Key, or environment variable '{options.KeyEnv}'.");
|
||||
}
|
||||
|
||||
private static string ToReasoningEffortValue(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => "none",
|
||||
ReasoningEffortOption.Low => "low",
|
||||
ReasoningEffortOption.High => "high",
|
||||
ReasoningEffortOption.ExtraHigh => "xhigh",
|
||||
_ => "medium"
|
||||
};
|
||||
}
|
||||
|
||||
private static ReasoningEffort ToReasoningEffort(ReasoningEffortOption effort)
|
||||
{
|
||||
return effort switch
|
||||
{
|
||||
ReasoningEffortOption.None => ReasoningEffort.None,
|
||||
ReasoningEffortOption.Low => ReasoningEffort.Low,
|
||||
ReasoningEffortOption.High => ReasoningEffort.High,
|
||||
ReasoningEffortOption.ExtraHigh => ReasoningEffort.ExtraHigh,
|
||||
_ => ReasoningEffort.Medium
|
||||
};
|
||||
}
|
||||
}
|
||||
#pragma warning restore MAAI001
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public sealed class WorkflowRulesEditorChatViewModel
|
||||
{
|
||||
private readonly IWorkflowRulesEditorChatPipeline pipeline;
|
||||
|
||||
public WorkflowRulesEditorChatViewModel(IWorkflowRulesEditorChatPipeline pipeline)
|
||||
{
|
||||
this.pipeline = pipeline;
|
||||
}
|
||||
|
||||
public ObservableCollection<WorkflowRulesEditorChatMessage> Messages { get; } = [];
|
||||
|
||||
public string Draft { get; set; } = "";
|
||||
|
||||
public bool IsThinking { get; private set; }
|
||||
|
||||
public event EventHandler? Changed;
|
||||
|
||||
public async Task SendAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var prompt = Draft.Trim();
|
||||
if (string.IsNullOrWhiteSpace(prompt) || IsThinking)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Draft = "";
|
||||
var priorConversation = Messages.ToList();
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, prompt));
|
||||
IsThinking = true;
|
||||
OnChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await pipeline.SendAsync(
|
||||
priorConversation,
|
||||
prompt,
|
||||
cancellationToken);
|
||||
Messages.Clear();
|
||||
foreach (var message in result.Conversation)
|
||||
{
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Messages.Add(new WorkflowRulesEditorChatMessage(
|
||||
WorkflowRulesEditorChatRole.Agent,
|
||||
$"Rules editor failed: {exception.Message}"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsThinking = false;
|
||||
OnChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChanged()
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorInstructionBuilder
|
||||
{
|
||||
Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditorInstructionBuilder
|
||||
{
|
||||
private const string DefaultPrompt = """
|
||||
You are the Meeting Assistant workflow rules and identities editor.
|
||||
|
||||
Your purpose is to help edit the configured local workflow rules YAML file and manage the local speaker identity database for Meeting Assistant.
|
||||
Use the read_rules, search, and write_rules tools for workflow rules. Do not ask for or modify unrelated files.
|
||||
Read the existing rules before making changes unless the user explicitly asks to replace the whole file.
|
||||
Preserve valid YAML, keep personal/local rules in the configured ignored rules file, and keep changes minimal.
|
||||
When writing rules, prefer existing rule style and names.
|
||||
Use search_identities and read_identity before changing identities unless the user gives an exact identity id.
|
||||
Prefer updating identities by id, not by guessed name. If a user asks about names, search first and confirm ambiguity in your final response.
|
||||
For identity merges, merge the duplicate/source identity into the identity that should remain as target.
|
||||
Use list_identity_samples before playing, reading, or deleting samples. Use queue_play_identity_sample to let the user hear a sample; do not claim you heard it yourself.
|
||||
Use read_identity_sample only when the raw base64 WAV is actually useful; prefer list_identity_samples for ordinary inspection.
|
||||
Delete identities or samples only when the user clearly asked for deletion or cleanup.
|
||||
Explain the final change briefly after the tools finish.
|
||||
""";
|
||||
|
||||
private readonly ILogger<WorkflowRulesEditorInstructionBuilder> logger;
|
||||
|
||||
public WorkflowRulesEditorInstructionBuilder(ILogger<WorkflowRulesEditorInstructionBuilder> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> BuildAsync(MeetingAssistantOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
var editorOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
||||
var configuredPrompt = string.IsNullOrWhiteSpace(editorOptions.InitialPrompt)
|
||||
? DefaultPrompt
|
||||
: editorOptions.InitialPrompt!;
|
||||
var rulesPath = WorkflowRulesPathResolver.Resolve(options.Automation.RulesPath) ?? "<not configured>";
|
||||
var docs = await ReadWorkflowDocsAsync(cancellationToken);
|
||||
|
||||
return configuredPrompt.Trim() + Environment.NewLine + Environment.NewLine +
|
||||
$"Configured workflow rules file: {rulesPath}" + Environment.NewLine + Environment.NewLine +
|
||||
"Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine +
|
||||
"Workflow rules reference documentation:" + Environment.NewLine +
|
||||
"```markdown" + Environment.NewLine +
|
||||
docs.Trim() + Environment.NewLine +
|
||||
"```";
|
||||
}
|
||||
|
||||
private async Task<string> ReadWorkflowDocsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var path in CandidateDocumentationPaths())
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return await File.ReadAllTextAsync(path, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogWarning("Workflow rules editor could not find docs/meeting-workflow-engine.md for its prompt");
|
||||
return "Workflow documentation file docs/meeting-workflow-engine.md was not available at runtime.";
|
||||
}
|
||||
|
||||
private static IEnumerable<string> CandidateDocumentationPaths()
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
yield return Path.Combine(baseDirectory, "docs", "meeting-workflow-engine.md");
|
||||
|
||||
var current = new DirectoryInfo(baseDirectory);
|
||||
for (var i = 0; i < 8 && current is not null; i++)
|
||||
{
|
||||
yield return Path.Combine(current.FullName, "docs", "meeting-workflow-engine.md");
|
||||
current = current.Parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal enum WorkflowRulesEditorMarkdownInlineStyle
|
||||
{
|
||||
Normal,
|
||||
Italic,
|
||||
Bold,
|
||||
Code
|
||||
}
|
||||
|
||||
internal abstract record WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownParagraph(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> Inlines) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownCodeBlock(string Text) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownTable(
|
||||
IReadOnlyList<string> Header,
|
||||
IReadOnlyList<IReadOnlyList<string>> Rows) : WorkflowRulesEditorMarkdownBlock;
|
||||
|
||||
internal sealed record WorkflowRulesEditorMarkdownInline(
|
||||
string Text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle Style);
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdown
|
||||
{
|
||||
public static IReadOnlyList<WorkflowRulesEditorMarkdownBlock> Parse(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var blocks = new List<WorkflowRulesEditorMarkdownBlock>();
|
||||
var paragraph = new List<string>();
|
||||
var code = new List<string>();
|
||||
var inCodeBlock = false;
|
||||
var lines = ReadLines(text).ToArray();
|
||||
|
||||
for (var index = 0; index < lines.Length; index++)
|
||||
{
|
||||
var line = lines[index];
|
||||
if (line.TrimStart().StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
if (inCodeBlock)
|
||||
{
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
|
||||
code.Clear();
|
||||
inCodeBlock = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
inCodeBlock = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock)
|
||||
{
|
||||
code.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsTableStart(lines, index))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
var header = ParseTableRow(lines[index]);
|
||||
index += 2;
|
||||
var rows = new List<IReadOnlyList<string>>();
|
||||
while (index < lines.Length && IsTableRow(lines[index]))
|
||||
{
|
||||
rows.Add(ParseTableRow(lines[index]));
|
||||
index++;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownTable(header, rows));
|
||||
index--;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
FlushParagraph(blocks, paragraph);
|
||||
}
|
||||
else
|
||||
{
|
||||
paragraph.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock)
|
||||
{
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownCodeBlock(string.Join(Environment.NewLine, code)));
|
||||
}
|
||||
|
||||
FlushParagraph(blocks, paragraph);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WorkflowRulesEditorMarkdownInline> ParseInline(string text)
|
||||
{
|
||||
var result = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
var index = 0;
|
||||
while (index < text.Length)
|
||||
{
|
||||
var next = FindNextMarker(text, index);
|
||||
if (next < 0)
|
||||
{
|
||||
AddInline(result, text[index..], WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
break;
|
||||
}
|
||||
|
||||
if (next > index)
|
||||
{
|
||||
AddInline(result, text[index..next], WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
}
|
||||
|
||||
if (text[next] == '`')
|
||||
{
|
||||
var end = text.IndexOf('`', next + 1);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Code);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text.AsSpan(next).StartsWith("**", StringComparison.Ordinal))
|
||||
{
|
||||
var end = text.IndexOf("**", next + 2, StringComparison.Ordinal);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 2)..end], WorkflowRulesEditorMarkdownInlineStyle.Bold);
|
||||
index = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (text[next] == '*')
|
||||
{
|
||||
var end = text.IndexOf('*', next + 1);
|
||||
if (end > next)
|
||||
{
|
||||
AddInline(result, text[(next + 1)..end], WorkflowRulesEditorMarkdownInlineStyle.Italic);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
AddInline(result, text[next].ToString(), WorkflowRulesEditorMarkdownInlineStyle.Normal);
|
||||
index = next + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void FlushParagraph(
|
||||
List<WorkflowRulesEditorMarkdownBlock> blocks,
|
||||
List<string> paragraph)
|
||||
{
|
||||
if (paragraph.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
blocks.Add(new WorkflowRulesEditorMarkdownParagraph(ParseInline(string.Join('\n', paragraph))));
|
||||
paragraph.Clear();
|
||||
}
|
||||
|
||||
private static bool IsTableStart(IReadOnlyList<string> lines, int index)
|
||||
{
|
||||
return index + 1 < lines.Count &&
|
||||
IsTableRow(lines[index]) &&
|
||||
IsTableSeparator(lines[index + 1]);
|
||||
}
|
||||
|
||||
private static bool IsTableRow(string line)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(line) && line.Contains('|', StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsTableSeparator(string line)
|
||||
{
|
||||
var cells = ParseTableRow(line);
|
||||
return cells.Count > 0 &&
|
||||
cells.All(cell =>
|
||||
{
|
||||
var trimmed = cell.Trim();
|
||||
return trimmed.Contains('-', StringComparison.Ordinal) &&
|
||||
trimmed.All(character => character is '-' or ':' or ' ');
|
||||
});
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseTableRow(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.StartsWith('|'))
|
||||
{
|
||||
trimmed = trimmed[1..];
|
||||
}
|
||||
|
||||
if (trimmed.EndsWith('|'))
|
||||
{
|
||||
trimmed = trimmed[..^1];
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.Split('|')
|
||||
.Select(cell => cell.Trim())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void AddInline(
|
||||
List<WorkflowRulesEditorMarkdownInline> result,
|
||||
string text,
|
||||
WorkflowRulesEditorMarkdownInlineStyle style)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Count > 0 && result[^1].Style == style)
|
||||
{
|
||||
result[^1] = result[^1] with { Text = result[^1].Text + text };
|
||||
return;
|
||||
}
|
||||
|
||||
result.Add(new WorkflowRulesEditorMarkdownInline(text, style));
|
||||
}
|
||||
|
||||
private static int FindNextMarker(string text, int start)
|
||||
{
|
||||
var code = text.IndexOf('`', start);
|
||||
var bold = text.IndexOf("**", start, StringComparison.Ordinal);
|
||||
var italic = text.IndexOf('*', start);
|
||||
if (italic >= 0 && italic + 1 < text.Length && text[italic + 1] == '*')
|
||||
{
|
||||
italic = text.IndexOf('*', italic + 2);
|
||||
}
|
||||
|
||||
return new[] { code, bold, italic }
|
||||
.Where(index => index >= 0)
|
||||
.DefaultIfEmpty(-1)
|
||||
.Min();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ReadLines(string text)
|
||||
{
|
||||
using var reader = new StringReader(text);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
#if WINDOWS
|
||||
using Aprillz.MewUI;
|
||||
using Aprillz.MewUI.Controls;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorMarkdownRenderer
|
||||
{
|
||||
public static UIElement CreateContent(string content)
|
||||
{
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(7);
|
||||
foreach (var block in WorkflowRulesEditorMarkdown.Parse(content))
|
||||
{
|
||||
panel.Add(block switch
|
||||
{
|
||||
WorkflowRulesEditorMarkdownCodeBlock codeBlock => CreateCodeBlock(codeBlock),
|
||||
WorkflowRulesEditorMarkdownTable table => CreateTable(table),
|
||||
WorkflowRulesEditorMarkdownParagraph paragraph => CreateParagraph(paragraph),
|
||||
_ => new TextBlock()
|
||||
});
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraph(WorkflowRulesEditorMarkdownParagraph paragraph)
|
||||
{
|
||||
var lines = SplitInlineLines(paragraph.Inlines);
|
||||
if (lines.Count == 1)
|
||||
{
|
||||
return CreateParagraphLine(lines[0]);
|
||||
}
|
||||
|
||||
var panel = new StackPanel()
|
||||
.Vertical()
|
||||
.Spacing(3);
|
||||
foreach (var line in lines)
|
||||
{
|
||||
panel.Add(CreateParagraphLine(line));
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement CreateParagraphLine(IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var linePanel = new WrapPanel()
|
||||
.Spacing(1);
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
foreach (var element in CreateInlineElements(inline))
|
||||
{
|
||||
linePanel.Add(element);
|
||||
}
|
||||
}
|
||||
|
||||
return linePanel;
|
||||
}
|
||||
|
||||
private static IEnumerable<UIElement> CreateInlineElements(WorkflowRulesEditorMarkdownInline inline)
|
||||
{
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code)
|
||||
{
|
||||
yield return CreateInlineCode(inline.Text);
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var token in SplitInlineText(inline.Text))
|
||||
{
|
||||
var text = new TextBlock()
|
||||
.Text(token)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243));
|
||||
if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold)
|
||||
{
|
||||
text.FontWeight(FontWeight.Bold);
|
||||
}
|
||||
else if (inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Italic)
|
||||
{
|
||||
text.FontFamily("Segoe UI Italic");
|
||||
}
|
||||
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<IReadOnlyList<WorkflowRulesEditorMarkdownInline>> SplitInlineLines(
|
||||
IReadOnlyList<WorkflowRulesEditorMarkdownInline> inlines)
|
||||
{
|
||||
var lines = new List<IReadOnlyList<WorkflowRulesEditorMarkdownInline>>();
|
||||
var current = new List<WorkflowRulesEditorMarkdownInline>();
|
||||
foreach (var inline in inlines)
|
||||
{
|
||||
var parts = inline.Text.Split('\n');
|
||||
for (var index = 0; index < parts.Length; index++)
|
||||
{
|
||||
if (parts[index].Length > 0)
|
||||
{
|
||||
current.Add(inline with { Text = parts[index] });
|
||||
}
|
||||
|
||||
if (index < parts.Length - 1)
|
||||
{
|
||||
lines.Add(current);
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.Add(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static UIElement CreateInlineCode(string text)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(4, 1, 4, 2)
|
||||
.Margin(1, 0, 1, 0)
|
||||
.CornerRadius(4)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(24, 28, 35))
|
||||
.Child(new TextBlock()
|
||||
.Text(text)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
}
|
||||
|
||||
private static UIElement CreateTable(WorkflowRulesEditorMarkdownTable table)
|
||||
{
|
||||
var rows = table.Rows
|
||||
.Select(row => new MarkdownTableRow(PadCells(row, table.Header.Count)))
|
||||
.ToArray();
|
||||
var gridView = new GridView()
|
||||
.HeaderHeight(30)
|
||||
.RowHeight(30)
|
||||
.CellPadding(new Thickness(8, 4, 8, 4))
|
||||
.ShowGridLines(true)
|
||||
.ZebraStriping(true);
|
||||
|
||||
for (var index = 0; index < table.Header.Count; index++)
|
||||
{
|
||||
var columnIndex = index;
|
||||
gridView.AddColumn<MarkdownTableRow>(
|
||||
table.Header[index],
|
||||
GetMarkdownTableColumnWidth(table, index),
|
||||
_ => new TextBlock()
|
||||
.TextWrapping(TextWrapping.NoWrap)
|
||||
.Foreground(Color.FromRgb(230, 235, 243)),
|
||||
(element, row, _, _) => ((TextBlock)element).Text(row.GetCell(columnIndex)),
|
||||
minWidth: 64,
|
||||
resizable: true);
|
||||
}
|
||||
|
||||
return gridView
|
||||
.ItemsSource(rows)
|
||||
.MinWidth(Math.Min(900, table.Header.Count * 120))
|
||||
.Height(Math.Min(420, 30 + Math.Max(1, rows.Length) * 30));
|
||||
}
|
||||
|
||||
private static UIElement CreateCodeBlock(WorkflowRulesEditorMarkdownCodeBlock codeBlock)
|
||||
{
|
||||
return new Border()
|
||||
.Padding(9)
|
||||
.CornerRadius(6)
|
||||
.BorderThickness(1)
|
||||
.BorderBrush(Color.FromRgb(86, 94, 108))
|
||||
.Background(Color.FromRgb(20, 24, 31))
|
||||
.Child(new TextBlock()
|
||||
.Text(codeBlock.Text)
|
||||
.TextWrapping(TextWrapping.Wrap)
|
||||
.FontFamily("Consolas")
|
||||
.Foreground(Color.FromRgb(237, 241, 247)));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitInlineText(string text)
|
||||
{
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
{
|
||||
var end = start;
|
||||
var isWhitespace = char.IsWhiteSpace(text[start]);
|
||||
while (end < text.Length && char.IsWhiteSpace(text[end]) == isWhitespace)
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
var token = text[start..end];
|
||||
if (token.Length > 0)
|
||||
{
|
||||
yield return token;
|
||||
}
|
||||
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> PadCells(IReadOnlyList<string> cells, int count)
|
||||
{
|
||||
if (cells.Count >= count)
|
||||
{
|
||||
return cells;
|
||||
}
|
||||
|
||||
return cells
|
||||
.Concat(Enumerable.Repeat("", count - cells.Count))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static double GetMarkdownTableColumnWidth(
|
||||
WorkflowRulesEditorMarkdownTable table,
|
||||
int index)
|
||||
{
|
||||
var maxLength = new[] { table.Header[index].Length }
|
||||
.Concat(table.Rows.Select(row => index < row.Count ? row[index].Length : 0))
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
return Math.Clamp(maxLength * 8 + 28, 72, 260);
|
||||
}
|
||||
|
||||
private sealed record MarkdownTableRow(IReadOnlyList<string> Cells)
|
||||
{
|
||||
public string GetCell(int index)
|
||||
{
|
||||
return index >= 0 && index < Cells.Count
|
||||
? Cells[index]
|
||||
: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Threading.Channels;
|
||||
using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public interface IWorkflowRulesEditorSamplePlaybackQueue
|
||||
{
|
||||
Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class WorkflowRulesEditorSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue, IDisposable
|
||||
{
|
||||
private readonly Channel<QueuedSample> channel = Channel.CreateUnbounded<QueuedSample>();
|
||||
private readonly ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger;
|
||||
private readonly CancellationTokenSource cancellation = new();
|
||||
private readonly Task playbackTask;
|
||||
|
||||
public WorkflowRulesEditorSamplePlaybackQueue(ILogger<WorkflowRulesEditorSamplePlaybackQueue> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
playbackTask = Task.Run(PlayQueuedSamplesAsync);
|
||||
}
|
||||
|
||||
public async Task<string> QueueAsync(
|
||||
int sampleId,
|
||||
byte[] wavBytes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (wavBytes.Length == 0)
|
||||
{
|
||||
return $"Sample {sampleId} is empty and was not queued.";
|
||||
}
|
||||
|
||||
await channel.Writer.WriteAsync(new QueuedSample(sampleId, wavBytes), cancellationToken);
|
||||
return $"Queued sample {sampleId} for playback.";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation.Cancel();
|
||||
channel.Writer.TryComplete();
|
||||
try
|
||||
{
|
||||
playbackTask.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
cancellation.Dispose();
|
||||
}
|
||||
|
||||
private async Task PlayQueuedSamplesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var sample in channel.Reader.ReadAllAsync(cancellation.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
await PlayAsync(sample, cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"Could not play queued speaker identity sample {SampleId}",
|
||||
sample.SampleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task PlayAsync(QueuedSample sample, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = new MemoryStream(sample.WavBytes, writable: false);
|
||||
using var reader = new WaveFileReader(stream);
|
||||
using var output = new WaveOutEvent();
|
||||
var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
output.PlaybackStopped += (_, _) => completed.TrySetResult();
|
||||
output.Init(reader);
|
||||
output.Play();
|
||||
|
||||
await using var registration = cancellationToken.Register(() =>
|
||||
{
|
||||
output.Stop();
|
||||
completed.TrySetCanceled(cancellationToken);
|
||||
});
|
||||
await completed.Task;
|
||||
}
|
||||
|
||||
private sealed record QueuedSample(int SampleId, byte[] WavBytes);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
internal static class WorkflowRulesEditorScrollPolicy
|
||||
{
|
||||
private const double BottomTolerance = 8;
|
||||
|
||||
public static bool ShouldAutoScroll(
|
||||
double verticalOffset,
|
||||
double viewportHeight,
|
||||
double previousContentHeight)
|
||||
{
|
||||
if (viewportHeight <= 0 || previousContentHeight <= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previousContentHeight <= viewportHeight)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return verticalOffset + viewportHeight >= previousContentHeight - BottomTolerance;
|
||||
}
|
||||
|
||||
public static double GetBottomOffset(
|
||||
double viewportHeight,
|
||||
double contentHeight)
|
||||
{
|
||||
return Math.Max(0, contentHeight - viewportHeight);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MeetingAssistant.Workflow;
|
||||
|
||||
public static class WorkflowRulesPathResolver
|
||||
{
|
||||
public static string? Resolve(string? configuredPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(configuredPath);
|
||||
return Path.IsPathRooted(expanded)
|
||||
? Path.GetFullPath(expanded)
|
||||
: Path.GetFullPath(expanded);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user