Files
meeting-assistant/MeetingAssistant/Workflow/WorkflowRulesEditorChatPipeline.cs
T
codex e192ae7cd8
PR and Push Build/Test / build-and-test (push) Successful in 14m17s
fix: support LiteLLM Responses streaming
2026-07-27 14:44:32 +02:00

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