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

240 lines
11 KiB
C#

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