Public Access
348 lines
15 KiB
C#
348 lines
15 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. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadTranscript,
|
|
"read_transcript",
|
|
"Read the transcript markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadContext,
|
|
"read_context",
|
|
"Read the assistant context markdown for the current meeting. With no line arguments, read the whole file. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadUserNotes,
|
|
"read_usernotes",
|
|
"Read the user-authored body notes from the meeting note. With no line arguments, read the whole body. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteContext,
|
|
"write_context",
|
|
"Write internal assistant notes to the assistant context body. With no line arguments, append to the body. Set replace_file=true only when intentionally replacing the whole 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. Supports from/to or tail. 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 and return the written summary filename. Provide title to rename the meeting when the current meeting title is missing or is still a generated default and the meeting purpose is clear."),
|
|
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. Supports from/to or tail."),
|
|
AIFunctionFactory.Create(
|
|
tools.WriteProjectFile,
|
|
"write_projectfile",
|
|
"Write a file inside an existing project folder. With no line arguments, append or create the file. Set replace_file=true only when intentionally replacing the whole 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.ListPastProjectMeetings,
|
|
"list_past_project_meetings",
|
|
"List paginated past meeting summary notes from the configured summaries folder for projects assigned to the current meeting. Optional projects must be assigned to the current meeting."),
|
|
AIFunctionFactory.Create(
|
|
tools.ReadPastProjectMeetingSummary,
|
|
"read_past_project_meeting_summary",
|
|
"Read a past project meeting summary note as read-only historical context. The path is relative to the configured summaries folder. Supports from/to or tail. Cannot read or write the current meeting summary."),
|
|
AIFunctionFactory.Create(
|
|
tools.Search,
|
|
"search",
|
|
"Search project files and past meeting summaries for the requested current-meeting project scope, or all projects assigned to the current meeting when projects is omitted. Use file_pattern to limit project files by glob.")
|
|
];
|
|
}
|
|
|
|
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
|