Public Access
Implement meeting assistant v1
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
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 const string Instructions = """
|
||||
You are the Meeting Assistant summary agent.
|
||||
|
||||
Use the provided tools to read the meeting transcript, assistant context, user notes, glossary, and project files.
|
||||
All read tools can return the whole file or a clamped inclusive line range when from and to are supplied; use ranges for large inputs before asking for more lines.
|
||||
Then write the finished meeting summary as markdown by calling write_summary. If the meeting note has no title, provide a concise title parameter to write_summary.
|
||||
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
|
||||
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
|
||||
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
|
||||
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
|
||||
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
|
||||
Keep the output grounded in the source material and explicitly say when a section has no known items.
|
||||
""";
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly ILoggerFactory loggerFactory;
|
||||
private readonly ILogger<OpenAiMeetingSummaryAgentPipeline> logger;
|
||||
private readonly IServiceProvider services;
|
||||
private readonly IMeetingSummaryFailureWriter failureWriter;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<OpenAiMeetingSummaryAgentPipeline> logger,
|
||||
IServiceProvider services,
|
||||
IMeetingSummaryFailureWriter failureWriter)
|
||||
{
|
||||
this.options = options.Value;
|
||||
this.loggerFactory = loggerFactory;
|
||||
this.logger = logger;
|
||||
this.services = services;
|
||||
this.failureWriter = failureWriter;
|
||||
}
|
||||
|
||||
public async Task<MeetingSummaryRunResult> RunAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var agentOptions = options.Agent;
|
||||
var key = ResolveApiKey(agentOptions);
|
||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options));
|
||||
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);
|
||||
|
||||
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);
|
||||
return await failureWriter.WriteAsync(artifacts, exception, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
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.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
|
||||
Reference in New Issue
Block a user