Public Access
Add meeting assistant speech and summary automation
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public interface IMeetingSummaryInstructionBuilder
|
||||
{
|
||||
Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
return null;
|
||||
}
|
||||
|
||||
var meetingNotesFolder = VaultPath.Resolve(options.Vault.MeetingNotesFolder);
|
||||
var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
|
||||
if (!Directory.Exists(meetingNotesFolder))
|
||||
{
|
||||
return null;
|
||||
@@ -65,7 +65,7 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
|
||||
return null;
|
||||
}
|
||||
|
||||
var summariesFolder = VaultPath.Resolve(options.Vault.SummariesFolder);
|
||||
var summariesFolder = VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
|
||||
var requested = Environment.ExpandEnvironmentVariables(summaryPath);
|
||||
var fullPath = Path.IsPathRooted(requested)
|
||||
? Path.GetFullPath(requested)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace MeetingAssistant.Summary;
|
||||
|
||||
public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructionBuilder
|
||||
{
|
||||
public const string DefaultInitialPrompt = """
|
||||
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.
|
||||
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
|
||||
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 static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
|
||||
private readonly MeetingAssistantOptions options;
|
||||
|
||||
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
|
||||
{
|
||||
this.options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<string> BuildAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
|
||||
? DefaultInitialPrompt
|
||||
: options.Agent.InitialPrompt.Trim();
|
||||
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(projectInstructions)
|
||||
? instructions
|
||||
: instructions.TrimEnd() + "\n\n" + projectInstructions;
|
||||
}
|
||||
|
||||
private async Task<string> BuildProjectInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, cancellationToken);
|
||||
if (projects.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var blocks = projects.Select(project =>
|
||||
$"# {project.Name}\n\n{project.Instructions.Trim()}");
|
||||
return "---\nprojects:\n\n" + string.Join("\n\n", blocks);
|
||||
}
|
||||
|
||||
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
|
||||
if (projectNames.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projectsRoot = VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
if (!Directory.Exists(projectsRoot))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var projects = new List<ProjectInstructions>();
|
||||
foreach (var projectDirectory in Directory.EnumerateDirectories(projectsRoot).Order(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var projectName = Path.GetFileName(projectDirectory);
|
||||
if (!projectNames.Contains(projectName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var agentsPath = Path.Combine(projectDirectory, "AGENTS.md");
|
||||
if (!File.Exists(agentsPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
projects.Add(new ProjectInstructions(projectName, content));
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
private static async Task<HashSet<string>> ReadMeetingProjectNamesAsync(
|
||||
string meetingNotePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(meetingNotePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(meetingNotePath, cancellationToken);
|
||||
var document = MarkdownDocumentParser.SplitOptional(content);
|
||||
if (!document.HasFrontmatter)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var frontmatter = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter)
|
||||
?? new ProjectFrontmatter();
|
||||
return (frontmatter.Projects ?? [])
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project))
|
||||
.Select(project => project.Trim())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ProjectInstructions(string Name, string Instructions);
|
||||
|
||||
private sealed class ProjectFrontmatter
|
||||
{
|
||||
[YamlMember(Alias = "projects")]
|
||||
public List<string>? Projects { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Transcription;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using YamlDotNet.Serialization;
|
||||
@@ -13,16 +14,21 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
private readonly MeetingSessionArtifacts artifacts;
|
||||
private readonly MeetingAssistantOptions options;
|
||||
private readonly IDictationWordStore? dictationWordStore;
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
|
||||
: this(artifacts, new MeetingAssistantOptions())
|
||||
: this(artifacts, new MeetingAssistantOptions(), null)
|
||||
{
|
||||
}
|
||||
|
||||
public MeetingSummaryTools(MeetingSessionArtifacts artifacts, MeetingAssistantOptions options)
|
||||
public MeetingSummaryTools(
|
||||
MeetingSessionArtifacts artifacts,
|
||||
MeetingAssistantOptions options,
|
||||
IDictationWordStore? dictationWordStore = null)
|
||||
{
|
||||
this.artifacts = artifacts;
|
||||
this.options = options;
|
||||
this.dictationWordStore = dictationWordStore;
|
||||
}
|
||||
|
||||
public Task<string> ReadTranscript(int? @from = null, int? to = null)
|
||||
@@ -55,7 +61,23 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
public Task<string> ReadGlossary(int? @from = null, int? to = null)
|
||||
{
|
||||
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault.DictationWordsPath), @from, to);
|
||||
return ReadIfExistsAsync(VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath), @from, to);
|
||||
}
|
||||
|
||||
public async Task<string> AddDictationWord(string word)
|
||||
{
|
||||
if (dictationWordStore is null)
|
||||
{
|
||||
return "Dictation word store is not available.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(word))
|
||||
{
|
||||
return "Refused: word must not be empty.";
|
||||
}
|
||||
|
||||
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
|
||||
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
|
||||
}
|
||||
|
||||
public async Task<string> WriteSummary(string markdown, string? title = null)
|
||||
@@ -436,7 +458,7 @@ public sealed class MeetingSummaryTools
|
||||
|
||||
private string GetProjectsRoot()
|
||||
{
|
||||
return VaultPath.Resolve(options.Vault.ProjectsFolder);
|
||||
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
|
||||
}
|
||||
|
||||
private static async Task<string?> RunRipgrepAsync(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -9,38 +10,30 @@ 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;
|
||||
private readonly IMeetingSummaryInstructionBuilder instructionBuilder;
|
||||
private readonly IDictationWordStore dictationWordStore;
|
||||
|
||||
public OpenAiMeetingSummaryAgentPipeline(
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<OpenAiMeetingSummaryAgentPipeline> logger,
|
||||
IServiceProvider services,
|
||||
IMeetingSummaryFailureWriter failureWriter)
|
||||
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(
|
||||
@@ -49,7 +42,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
{
|
||||
var agentOptions = options.Agent;
|
||||
var key = ResolveApiKey(agentOptions);
|
||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options));
|
||||
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore));
|
||||
var instructions = await instructionBuilder.BuildAsync(artifacts, cancellationToken);
|
||||
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
|
||||
new Uri(agentOptions.Endpoint),
|
||||
key,
|
||||
@@ -76,7 +70,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
.UseFunctionInvocation()
|
||||
.Build()
|
||||
.AsAIAgent(
|
||||
Instructions,
|
||||
instructions,
|
||||
"meeting_summary",
|
||||
"Writes the markdown summary note for a captured meeting.",
|
||||
tools,
|
||||
@@ -135,6 +129,10 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user