Files
meeting-assistant/MeetingAssistant/Summary/OpenAiMeetingSummaryAgentPipeline.cs
T
codex 7777b349a5
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
Archive summary refinements and profile switching
2026-05-27 23:11:21 +02:00

340 lines
14 KiB
C#

using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Transcription;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Compaction;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
namespace MeetingAssistant.Summary;
#pragma warning disable MAAI001
public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
{
private readonly MeetingAssistantOptions options;
private readonly ILoggerFactory loggerFactory;
private readonly ILogger<OpenAiMeetingSummaryAgentPipeline> logger;
private readonly IServiceProvider services;
private readonly IMeetingSummaryFailureWriter failureWriter;
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
private readonly IDictationWordStore dictationWordStore;
public OpenAiMeetingSummaryAgentPipeline(
IOptions<MeetingAssistantOptions> options,
ILoggerFactory loggerFactory,
ILogger<OpenAiMeetingSummaryAgentPipeline> logger,
IServiceProvider services,
IMeetingSummaryFailureWriter failureWriter,
IMeetingSummaryInstructionBuilder instructionBuilder,
IDictationWordStore dictationWordStore)
{
this.options = options.Value;
this.loggerFactory = loggerFactory;
this.logger = logger;
this.services = services;
this.failureWriter = failureWriter;
this.instructionBuilder = instructionBuilder;
this.dictationWordStore = dictationWordStore;
}
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
return await RunAsync(artifacts, options, cancellationToken);
}
public async Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var agentOptions = options.Agent;
var key = ResolveApiKey(agentOptions);
var writeAudit = new SummaryAgentWriteAudit(artifacts);
var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit);
var tools = CreateTools(meetingTools);
var instructions = await instructionBuilder.BuildAsync(artifacts, 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: false);
var agent = chatClient
.AsBuilder()
.UseFunctionInvocation()
.Build()
.AsAIAgent(
instructions,
"meeting_summary",
"Writes the markdown summary note for a captured meeting.",
tools,
loggerFactory: loggerFactory,
services: services);
try
{
var response = await agent.RunAsync(
"Write the summary for this meeting. Use the tools; do not invent missing information.",
session: null,
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
cancellationToken: cancellationToken);
if (!meetingTools.SummaryWasWritten)
{
throw CreateMissingSummaryWriteException(response);
}
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception)
{
logger.LogError(
exception,
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
artifacts.SummaryPath);
var result = await failureWriter.WriteAsync(artifacts, exception, options, cancellationToken);
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return result;
}
}
private static List<AITool> CreateTools(MeetingSummaryTools tools)
{
return
[
AIFunctionFactory.Create(
tools.ReadMeetingNote,
"read_meetingnote",
"Read the meeting note, including YAML frontmatter and user-authored body notes. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
AIFunctionFactory.Create(
tools.ReadTranscript,
"read_transcript",
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
AIFunctionFactory.Create(
tools.ReadContext,
"read_context",
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range."),
AIFunctionFactory.Create(
tools.ReadUserNotes,
"read_usernotes",
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. With from and to, read that clamped inclusive 1-based body line range."),
AIFunctionFactory.Create(
tools.WriteContext,
"write_context",
"Write internal assistant notes to the assistant context body. With no line arguments, overwrite the body. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
AIFunctionFactory.Create(
tools.ReadGlossary,
"read_glossary",
"Read the glossary or vocabulary context for this meeting. With no line arguments, read the whole file. With from and to, read that clamped inclusive 1-based line range. May be empty."),
AIFunctionFactory.Create(
tools.AddDictationWord,
"add_dictation_word",
"Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."),
AIFunctionFactory.Create(
tools.AddAttendee,
"add_attendee",
"Add one attendee to the current meeting note frontmatter when transcript or OCR evidence clearly shows they attended."),
AIFunctionFactory.Create(
tools.RemoveAttendee,
"remove_attendee",
"Remove one attendee from the current meeting note frontmatter when evidence clearly shows they did not attend. Do not remove attendees solely because a screenshot contains only partial participant evidence."),
AIFunctionFactory.Create(
tools.OverrideSpeaker,
"override_speaker",
"Replace a transcript speaker label with a named speaker only when the evidence is very certain. If the replacement speaker already exists, set merge=true only when both labels are certainly the same identity."),
AIFunctionFactory.Create(
tools.DeleteIdentity,
"delete_identity",
"Remove a wrongfully matched speaker identity and relabel transcript mentions to Removed-n only when the evidence is certain."),
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
"Write the finished summary markdown to the configured summary note. Provide title when the meeting note has no title."),
AIFunctionFactory.Create(
tools.ListProjects,
"list_projects",
"List the configured project folders bound to the current meeting note frontmatter."),
AIFunctionFactory.Create(
tools.ListProjectFiles,
"list_projectfiles",
"List markdown and other files in a bound project. The project parameter is a project folder name."),
AIFunctionFactory.Create(
tools.ReadProjectFile,
"read_projectfile",
"Read a bound project file, optionally clamping to from and to inclusive line numbers."),
AIFunctionFactory.Create(
tools.WriteProjectFile,
"write_projectfile",
"Write a file inside an existing project folder. With no line arguments, overwrite or create the file. With from and to, replace that inclusive 1-based line range. With insert, insert content at that 1-based line position."),
AIFunctionFactory.Create(
tools.Search,
"search",
"Run ripgrep syntax keywords against the requested bound projects, or all projects bound to the meeting note.")
];
}
private static InvalidOperationException CreateMissingSummaryWriteException(AgentResponse response)
{
var message = string.IsNullOrWhiteSpace(response.Text)
? "Summary agent completed without calling write_summary."
: $"Summary agent completed without calling write_summary. Response: {response.Text}";
return new InvalidOperationException(message);
}
private static ChatOptions CreateChatOptions(AgentOptions options)
{
var chatOptions = new ChatOptions
{
ModelId = options.Model,
MaxOutputTokens = options.MaxOutputTokens,
AllowMultipleToolCalls = true,
ToolMode = ChatToolMode.Auto
};
if (options.EnableThinking)
{
chatOptions.Reasoning = new ReasoningOptions
{
Effort = ToReasoningEffort(options.ReasoningEffort)
};
}
return chatOptions;
}
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 = CreateFallbackCompactionStrategy(
options.ContextWindowTokens,
options.MaxOutputTokens,
options.CompactionRemainingRatio,
summaryClient)
};
}
internal static CompactionStrategy CreateFallbackCompactionStrategyForTests(
int contextWindowTokens,
int maxOutputTokens,
double remainingRatio,
IChatClient? summaryClient)
{
return CreateFallbackCompactionStrategy(
contextWindowTokens,
maxOutputTokens,
remainingRatio,
summaryClient);
}
private static CompactionStrategy CreateFallbackCompactionStrategy(
int contextWindowTokens,
int maxOutputTokens,
double remainingRatio,
IChatClient? summaryClient)
{
var contextWindow = Math.Max(1, contextWindowTokens);
var outputReserve = Math.Clamp(maxOutputTokens, 0, contextWindow - 1);
var inputBudget = contextWindow - outputReserve;
var triggerTokens = Math.Max(1, (int)Math.Floor(inputBudget * (1 - Math.Clamp(remainingRatio, 0.01, 0.90))));
var target = CompactionTriggers.TokensBelow(Math.Max(1, triggerTokens - 1));
var trigger = CompactionTriggers.TokensExceed(triggerTokens);
var strategies = new List<CompactionStrategy>
{
new ToolResultCompactionStrategy(trigger, minimumPreservedGroups: 4, target)
};
if (summaryClient is not null)
{
strategies.Add(new SummarizationCompactionStrategy(
summaryClient,
trigger,
minimumPreservedGroups: 4,
target: target));
}
strategies.Add(new SlidingWindowCompactionStrategy(
trigger,
minimumPreservedTurns: 4,
target));
strategies.Add(new TruncationCompactionStrategy(
trigger,
minimumPreservedGroups: 1,
target));
return new PipelineCompactionStrategy(strategies);
}
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
};
}
private static string ResolveApiKey(AgentOptions options)
{
if (!string.IsNullOrWhiteSpace(options.Key))
{
return options.Key;
}
var value = Environment.GetEnvironmentVariable(options.KeyEnv);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
throw new InvalidOperationException(
$"No agent API key configured. Set MeetingAssistant:Agent:Key or environment variable '{options.KeyEnv}'.");
}
}
#pragma warning restore MAAI001