Public Access
406 lines
23 KiB
C#
406 lines
23 KiB
C#
using MeetingAssistant.LaunchProfiles;
|
|
using MeetingAssistant.MeetingNotes;
|
|
using MeetingAssistant.Recording;
|
|
using MeetingAssistant.Summary;
|
|
using MeetingAssistant.Speakers;
|
|
using MeetingAssistant.Transcription;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Configuration;
|
|
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;
|
|
private readonly MeetingRecordingCoordinator recordingCoordinator;
|
|
private readonly IMeetingMetadataProvider meetingMetadataProvider;
|
|
private readonly ILaunchProfileOptionsProvider launchProfiles;
|
|
private readonly ISpeakerIdentityMergeService identityMergeService;
|
|
private readonly AsrDiagnosticService asrDiagnosticService;
|
|
private readonly IConfiguration configuration;
|
|
|
|
public WorkflowRulesEditorChatPipeline(
|
|
IOptions<MeetingAssistantOptions> options,
|
|
ILoggerFactory loggerFactory,
|
|
ILogger<WorkflowRulesEditorChatPipeline> logger,
|
|
IWorkflowRulesEditorInstructionBuilder instructionBuilder,
|
|
IDbContextFactory<SpeakerIdentityDbContext> speakerIdentityDbContextFactory,
|
|
IWorkflowRulesEditorSamplePlaybackQueue samplePlaybackQueue,
|
|
MeetingRecordingCoordinator recordingCoordinator,
|
|
IMeetingMetadataProvider meetingMetadataProvider,
|
|
ILaunchProfileOptionsProvider launchProfiles,
|
|
ISpeakerIdentityMergeService identityMergeService,
|
|
AsrDiagnosticService asrDiagnosticService,
|
|
IConfiguration configuration)
|
|
{
|
|
this.options = options.Value;
|
|
this.loggerFactory = loggerFactory;
|
|
this.logger = logger;
|
|
this.instructionBuilder = instructionBuilder;
|
|
this.speakerIdentityDbContextFactory = speakerIdentityDbContextFactory;
|
|
this.samplePlaybackQueue = samplePlaybackQueue;
|
|
this.recordingCoordinator = recordingCoordinator;
|
|
this.meetingMetadataProvider = meetingMetadataProvider;
|
|
this.launchProfiles = launchProfiles;
|
|
this.identityMergeService = identityMergeService;
|
|
this.asrDiagnosticService = asrDiagnosticService;
|
|
this.configuration = configuration;
|
|
}
|
|
|
|
public async Task<WorkflowRulesEditorChatResult> SendAsync(
|
|
IReadOnlyList<WorkflowRulesEditorChatMessage> conversation,
|
|
string userMessage,
|
|
CancellationToken cancellationToken,
|
|
Action<WorkflowRulesEditorActivityUpdate>? activityChanged = null)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(userMessage))
|
|
{
|
|
return new WorkflowRulesEditorChatResult("");
|
|
}
|
|
|
|
var agentOptions = options.WorkflowRulesEditor.ToEffectiveAgentOptions(options.Agent);
|
|
var key = ResolveApiKey(agentOptions, "workflow rules editor");
|
|
var tools = new WorkflowRulesEditorTools(
|
|
options,
|
|
speakerIdentityDbContextFactory,
|
|
samplePlaybackQueue,
|
|
recordingCoordinator,
|
|
meetingMetadataProvider,
|
|
launchProfiles,
|
|
identityMergeService,
|
|
asrDiagnosticService,
|
|
configuration,
|
|
logger: logger);
|
|
var messages = conversation
|
|
.Select(ToChatMessage)
|
|
.Append(new ChatMessage(ChatRole.User, userMessage.Trim()))
|
|
.ToList();
|
|
var instructions = await instructionBuilder.BuildAsync(options, cancellationToken);
|
|
|
|
using var compactionSummaryClient = LiteLlmResponsesChatClient.Create(
|
|
agentOptions,
|
|
key,
|
|
compactionOptions: null,
|
|
logger,
|
|
firstRequestIsUser: false);
|
|
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
|
|
using var chatClient = LiteLlmResponsesChatClient.Create(
|
|
agentOptions,
|
|
key,
|
|
compactionOptions,
|
|
logger,
|
|
firstRequestIsUser: true,
|
|
retrying: () => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Status("Reconnecting...")),
|
|
reasoningSummaryChanged: text => activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.Thinking(text)));
|
|
var functionClient = chatClient
|
|
.AsBuilder()
|
|
.UseFunctionInvocation(loggerFactory, client =>
|
|
{
|
|
client.FunctionInvoker = async (context, token) =>
|
|
{
|
|
if (context.CallContent.Exception is null)
|
|
{
|
|
activityChanged?.Invoke(WorkflowRulesEditorActivityUpdate.ToolCall(context.Function.Name));
|
|
}
|
|
|
|
return await FunctionInvocationGuard.InvokeAsync(context, token);
|
|
};
|
|
})
|
|
.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();
|
|
return new WorkflowRulesEditorChatResult(responseText);
|
|
}
|
|
|
|
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. With tail, read the last lines."),
|
|
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 and supports an optional file_pattern glob."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadConfig,
|
|
"read_config",
|
|
"Read the local appsettings JSON configuration file. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. With tail, read the last lines."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteConfig,
|
|
"write_config",
|
|
"Replace the local appsettings JSON configuration file after validating that the supplied content is valid JSON. Configuration stores environment variable names, not secret values."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadConfigDocs,
|
|
"read_config_docs",
|
|
"Read Meeting Assistant configuration documentation from docs/meeting-assistant-configuration.md. Supports from/to or tail. Use this before explaining or changing unfamiliar configuration settings."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadLogs,
|
|
"read_logs",
|
|
"Read the application-owned log files from the temp log folder. Defaults to tailing the current log. Set logFile to meeting-assistant.log.1 through .4 for rotated files, or supply from/to for a clamped 1-based line range."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchLogs,
|
|
"search_logs",
|
|
"Search current and rotated application-owned log files with .NET regular expression syntax. Supports an optional file_pattern glob and returns filename:line text matches."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchSpec,
|
|
"search_spec",
|
|
"Search copied OpenSpec specification markdown files with .NET regular expression syntax. Use file_pattern to limit searched files by glob. Returns relative/path.md:line text matches scoped to openspec/specs."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadSpecFile,
|
|
"read_spec_file",
|
|
"Read one copied OpenSpec specification markdown file by path relative to openspec/specs. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.CreateProject,
|
|
"create_project",
|
|
"Create a new project folder under the configured projects folder. Use seed=recommended for PROJECT.md, JOURNAL.md, DECISIONS.md, and AGENTS.md from Project-AGENTS.md; seed=agents_md with agents_md to write AGENTS.md directly; or seed=none for an empty folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.ListProjects,
|
|
"list_projects",
|
|
"List all existing project folders under the configured projects folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.ListProjectFiles,
|
|
"list_projectfiles",
|
|
"List files inside an existing project folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadProjectFile,
|
|
"read_projectfile",
|
|
"Read a file inside an existing project folder. With no line arguments, read the whole file. With from/to, read that clamped inclusive 1-based line range. With tail, read the last lines."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteProjectFile,
|
|
"write_projectfile",
|
|
"Create or update a file inside an existing project folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchProjects,
|
|
"search_projects",
|
|
"Search files in all existing projects with .NET regular expression syntax. Use file_pattern to limit searched files by glob, for example *.md or **/*.md."),
|
|
AIFunctionFactory.Create(
|
|
tools.ListRecentSummaries,
|
|
"list_recent_summaries",
|
|
"List recent meeting summary notes from the configured summaries folder, newest first. Returns summary, meeting note, transcript, and assistant context paths to pass to the read tools."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadSummary,
|
|
"read_summary",
|
|
"Read one meeting summary file by path relative to the configured summaries folder. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadTranscript,
|
|
"read_transcript",
|
|
"Read one transcript file by path relative to the configured transcripts folder. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadMeetingNote,
|
|
"read_meeting_note",
|
|
"Read one meeting note file by path relative to the configured meeting notes folder. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadContext,
|
|
"read_context",
|
|
"Read one assistant context file by path relative to the configured assistant context folder. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteSummary,
|
|
"write_summary",
|
|
"Create or update a meeting summary file by path relative to the configured summaries folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteTranscript,
|
|
"write_transcript",
|
|
"Create or update a transcript file by path relative to the configured transcripts folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteMeetingNote,
|
|
"write_meeting_note",
|
|
"Create or update a meeting note file by path relative to the configured meeting notes folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteContext,
|
|
"write_context",
|
|
"Create or update an assistant context file by path relative to the configured assistant context folder. Appends by default, supports from/to replacement, insert, and replace_file for whole-file replacement."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteSummaryFrontmatter,
|
|
"write_summary_frontmatter",
|
|
"Replace only the YAML frontmatter of a meeting summary file while preserving the body. The path is relative to the configured summaries folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteTranscriptFrontmatter,
|
|
"write_transcript_frontmatter",
|
|
"Replace only the YAML frontmatter of a transcript file while preserving the body. The path is relative to the configured transcripts folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteMeetingNoteFrontmatter,
|
|
"write_meeting_note_frontmatter",
|
|
"Replace only the YAML frontmatter of a meeting note file while preserving the body. The path is relative to the configured meeting notes folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteContextFrontmatter,
|
|
"write_context_frontmatter",
|
|
"Replace only the YAML frontmatter of an assistant context file while preserving the body. The path is relative to the configured assistant context folder."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchSummaries,
|
|
"search_summaries",
|
|
"Search meeting summary files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchTranscripts,
|
|
"search_transcripts",
|
|
"Search transcript files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchMeetingNotes,
|
|
"search_meeting_notes",
|
|
"Search meeting note files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
|
AIFunctionFactory.Create(
|
|
tools.SearchContext,
|
|
"search_context",
|
|
"Search assistant context files with .NET regular expression syntax. Supports an optional file_pattern glob."),
|
|
AIFunctionFactory.Create(
|
|
tools.GetHealth,
|
|
"get_health",
|
|
"Return the same basic health information as the local /health endpoint."),
|
|
AIFunctionFactory.Create(
|
|
tools.GetRecordingStatus,
|
|
"get_recording_status",
|
|
"Return the current recording, artifact, process, and launch profile status without making an HTTP call."),
|
|
AIFunctionFactory.Create(
|
|
tools.DiagnoseCurrentMeeting,
|
|
"diagnose_current_meeting",
|
|
"Run the current meeting metadata diagnostic. Optional launch_profile selects a configured profile, started_at is a date/time, and timeout_seconds overrides the metadata lookup timeout."),
|
|
AIFunctionFactory.Create(
|
|
tools.MergeRecentSpeakerIdentities,
|
|
"merge_recent_speaker_identities",
|
|
"Run the recent speaker identity merge diagnostic. Optional recent_days overrides the configured merge age window; optional launch_profile selects profile options."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReloadWorkflowConfiguration,
|
|
"reload_workflow_configuration",
|
|
"Reload application configuration in-process and return the currently bound workflow rules path."),
|
|
AIFunctionFactory.Create(
|
|
tools.DiagnoseAsrTranscribeFile,
|
|
"diagnose_asr_transcribe_file",
|
|
"Run ASR diagnostics on a local WAV file and return live transcription segments. Optional launch_profile selects a configured ASR profile."),
|
|
AIFunctionFactory.Create(
|
|
tools.DiagnoseAsrDiarizeFile,
|
|
"diagnose_asr_diarize_file",
|
|
"Run ASR diarization diagnostics on a local WAV file and return finished segments. Optional num_speakers and launch_profile mirror the HTTP diagnostic endpoint."),
|
|
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",
|
|
"Refuse sampleless speaker identity creation and explain that identities must be created from an existing transcript speaker sample."),
|
|
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 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
|