Public Access
240 lines
11 KiB
C#
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",
|
|
"Append to the configured workflow rules YAML file by default after validating that the complete file parses as a workflow rules document. Set replace_file=true only when intentionally replacing the whole rules file."),
|
|
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
|