Public Access
307 lines
13 KiB
C#
307 lines
13 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 tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit));
|
|
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);
|
|
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);
|
|
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);
|
|
|
|
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.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 ChatOptions CreateChatOptions(AgentOptions options)
|
|
{
|
|
var chatOptions = new ChatOptions
|
|
{
|
|
ModelId = options.Model,
|
|
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
|