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 logger; private readonly IWorkflowRulesEditorInstructionBuilder instructionBuilder; private readonly IDbContextFactory 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 options, ILoggerFactory loggerFactory, ILogger logger, IWorkflowRulesEditorInstructionBuilder instructionBuilder, IDbContextFactory 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 SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken, Action? 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