Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -0,0 +1,74 @@
using MeetingAssistant.MeetingNotes;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
public sealed record BoundMeetingProject(string Name, string Path);
public sealed class BoundMeetingProjectResolver
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingAssistantOptions options;
public BoundMeetingProjectResolver(MeetingAssistantOptions options)
{
this.options = options;
}
public string ProjectsRoot => VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
public async Task<List<BoundMeetingProject>> GetBoundProjectsAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken = default)
{
if (!Directory.Exists(ProjectsRoot))
{
return [];
}
var projectNames = await ReadMeetingProjectNamesAsync(artifacts.MeetingNotePath, cancellationToken);
if (projectNames.Count == 0)
{
return [];
}
return Directory.EnumerateDirectories(ProjectsRoot)
.Select(path => new BoundMeetingProject(Path.GetFileName(path), path))
.Where(project => projectNames.Contains(project.Name))
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
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 class ProjectFrontmatter
{
[YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
}
@@ -7,4 +7,12 @@ public interface IMeetingSummaryInstructionBuilder
Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken);
Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return BuildAsync(artifacts, cancellationToken);
}
}
@@ -5,6 +5,14 @@ namespace MeetingAssistant.Summary;
public interface IMeetingSummaryPipeline
{
Task<MeetingSummaryRunResult> RunAsync(MeetingSessionArtifacts artifacts, CancellationToken cancellationToken);
Task<MeetingSummaryRunResult> RunAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return RunAsync(artifacts, cancellationToken);
}
}
public sealed record MeetingSummaryRunResult(
@@ -8,6 +8,14 @@ public interface IMeetingSummaryArtifactResolver
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
CancellationToken cancellationToken);
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
}
}
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
@@ -27,7 +35,15 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
string summaryPath,
CancellationToken cancellationToken)
{
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath);
return await ResolveBySummaryPathAsync(summaryPath, options, cancellationToken);
}
public async Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var requestedSummaryPath = ResolveRequestedSummaryPath(summaryPath, options);
if (requestedSummaryPath is null)
{
return null;
@@ -58,7 +74,9 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
return null;
}
private string? ResolveRequestedSummaryPath(string summaryPath)
private static string? ResolveRequestedSummaryPath(
string summaryPath,
MeetingAssistantOptions options)
{
if (string.IsNullOrWhiteSpace(summaryPath))
{
@@ -10,6 +10,15 @@ public interface IMeetingSummaryFailureWriter
MeetingSessionArtifacts artifacts,
Exception exception,
CancellationToken cancellationToken);
Task<MeetingSummaryRunResult> WriteAsync(
MeetingSessionArtifacts artifacts,
Exception exception,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return WriteAsync(artifacts, exception, cancellationToken);
}
}
public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
@@ -29,6 +38,15 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
MeetingSessionArtifacts artifacts,
Exception exception,
CancellationToken cancellationToken)
{
return await WriteAsync(artifacts, exception, options, cancellationToken);
}
public async Task<MeetingSummaryRunResult> WriteAsync(
MeetingSessionArtifacts artifacts,
Exception exception,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
var meetingNote = File.Exists(artifacts.MeetingNotePath)
@@ -43,7 +61,7 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(
frontmatter,
RenderFailureMarkdown(artifacts, exception)),
RenderFailureMarkdown(artifacts, exception, options)),
cancellationToken);
return new MeetingSummaryRunResult(
@@ -53,7 +71,10 @@ public sealed class MeetingSummaryFailureWriter : IMeetingSummaryFailureWriter
Error: exception.Message);
}
private string RenderFailureMarkdown(MeetingSessionArtifacts artifacts, Exception exception)
private static string RenderFailureMarkdown(
MeetingSessionArtifacts artifacts,
Exception exception,
MeetingAssistantOptions options)
{
var builder = new StringBuilder();
builder.AppendLine("# Meeting Summary");
@@ -1,6 +1,5 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
@@ -21,25 +20,31 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
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;
private readonly BoundMeetingProjectResolver projectResolver;
public MeetingSummaryInstructionBuilder(IOptions<MeetingAssistantOptions> options)
{
this.options = options.Value;
projectResolver = new BoundMeetingProjectResolver(this.options);
}
public async Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
CancellationToken cancellationToken)
{
return await BuildAsync(artifacts, options, cancellationToken);
}
public async Task<string> BuildAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var instructions = string.IsNullOrWhiteSpace(options.Agent.InitialPrompt)
? DefaultInitialPrompt
: options.Agent.InitialPrompt.Trim();
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, cancellationToken);
var projectInstructions = await BuildProjectInstructionsAsync(artifacts, options, cancellationToken);
return string.IsNullOrWhiteSpace(projectInstructions)
? instructions
: instructions.TrimEnd() + "\n\n" + projectInstructions;
@@ -47,9 +52,10 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
private async Task<string> BuildProjectInstructionsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, cancellationToken);
var projects = await GetBoundProjectsWithInstructionsAsync(artifacts, options, cancellationToken);
if (projects.Count == 0)
{
return "";
@@ -62,30 +68,16 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
private async Task<List<ProjectInstructions>> GetBoundProjectsWithInstructionsAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
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 resolver = ReferenceEquals(options, this.options)
? projectResolver
: new BoundMeetingProjectResolver(options);
var projects = new List<ProjectInstructions>();
foreach (var projectDirectory in Directory.EnumerateDirectories(projectsRoot).Order(StringComparer.OrdinalIgnoreCase))
foreach (var project in await resolver.GetBoundProjectsAsync(artifacts, cancellationToken))
{
var projectName = Path.GetFileName(projectDirectory);
if (!projectNames.Contains(projectName))
{
continue;
}
var agentsPath = Path.Combine(projectDirectory, "AGENTS.md");
var agentsPath = Path.Combine(project.Path, "AGENTS.md");
if (!File.Exists(agentsPath))
{
continue;
@@ -94,42 +86,12 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
var content = await File.ReadAllTextAsync(agentsPath, cancellationToken);
if (!string.IsNullOrWhiteSpace(content))
{
projects.Add(new ProjectInstructions(projectName, content));
projects.Add(new ProjectInstructions(project.Name, 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; }
}
}
+36 -57
View File
@@ -15,6 +15,8 @@ public sealed class MeetingSummaryTools
private readonly MeetingSessionArtifacts artifacts;
private readonly MeetingAssistantOptions options;
private readonly IDictationWordStore? dictationWordStore;
private readonly SummaryAgentWriteAudit? writeAudit;
private readonly BoundMeetingProjectResolver projectResolver;
public MeetingSummaryTools(MeetingSessionArtifacts artifacts)
: this(artifacts, new MeetingAssistantOptions(), null)
@@ -24,11 +26,14 @@ public sealed class MeetingSummaryTools
public MeetingSummaryTools(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options,
IDictationWordStore? dictationWordStore = null)
IDictationWordStore? dictationWordStore = null,
SummaryAgentWriteAudit? writeAudit = null)
{
this.artifacts = artifacts;
this.options = options;
this.dictationWordStore = dictationWordStore;
this.writeAudit = writeAudit;
projectResolver = new BoundMeetingProjectResolver(options);
}
public Task<string> ReadTranscript(int? @from = null, int? to = null)
@@ -76,7 +81,16 @@ public sealed class MeetingSummaryTools
return "Refused: word must not be empty.";
}
var words = await dictationWordStore.AddWordAsync(word, CancellationToken.None);
if (writeAudit is not null)
{
IReadOnlyList<string>? capturedWords = null;
await writeAudit.CaptureFileWriteAsync(
VaultPath.Resolve(options.Vault, options.Vault.DictationWordsPath),
async () => capturedWords = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None));
return $"Added '{word.Trim()}'. Dictionary now contains {capturedWords?.Count ?? 0} word(s).";
}
var words = await dictationWordStore.AddWordAsync(word, options, CancellationToken.None);
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
}
@@ -176,7 +190,17 @@ public sealed class MeetingSummaryTools
}
Directory.CreateDirectory(Path.GetDirectoryName(target.Path)!);
await WriteProjectFileContentAsync(target.Path, content, editMode);
if (writeAudit is not null)
{
await writeAudit.CaptureFileWriteAsync(
target.Path,
() => WriteProjectFileContentAsync(target.Path, content, editMode));
}
else
{
await WriteProjectFileContentAsync(target.Path, content, editMode);
}
return $"{target.Project.Name}/{ToToolPath(path)}";
}
@@ -237,7 +261,7 @@ public sealed class MeetingSummaryTools
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
}
private async Task<List<ProjectFolder>> GetSearchProjectsAsync(string[]? projects)
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();
if (projects is null || projects.Length == 0)
@@ -253,7 +277,7 @@ public sealed class MeetingSummaryTools
.ToList();
}
private async Task<ProjectFolder?> ResolveBoundProjectAsync(string project)
private async Task<BoundMeetingProject?> ResolveBoundProjectAsync(string project)
{
if (string.IsNullOrWhiteSpace(project))
{
@@ -297,7 +321,7 @@ public sealed class MeetingSummaryTools
}
var projectFolder = Directory.EnumerateDirectories(projectsRoot)
.Select(candidate => new ProjectFolder(Path.GetFileName(candidate), candidate))
.Select(candidate => new BoundMeetingProject(Path.GetFileName(candidate), candidate))
.FirstOrDefault(candidate => string.Equals(candidate.Name, project, StringComparison.OrdinalIgnoreCase));
if (projectFolder is null)
{
@@ -414,56 +438,19 @@ public sealed class MeetingSummaryTools
return string.Join('\n', lines);
}
private async Task<List<ProjectFolder>> GetBoundProjectsAsync()
private Task<List<BoundMeetingProject>> GetBoundProjectsAsync()
{
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
return [];
}
var projectNames = await ReadMeetingProjectNamesAsync();
if (projectNames.Count == 0)
{
return [];
}
return Directory.EnumerateDirectories(projectsRoot)
.Select(path => new ProjectFolder(Path.GetFileName(path), path))
.Where(project => projectNames.Contains(project.Name))
.OrderBy(project => project.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task<HashSet<string>> ReadMeetingProjectNamesAsync()
{
if (!File.Exists(artifacts.MeetingNotePath))
{
return [];
}
var content = await File.ReadAllTextAsync(artifacts.MeetingNotePath);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return [];
}
var note = YamlDeserializer.Deserialize<ProjectFrontmatter>(document.Frontmatter) ?? new ProjectFrontmatter();
return (note.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return projectResolver.GetBoundProjectsAsync(artifacts);
}
private string GetProjectsRoot()
{
return VaultPath.Resolve(options.Vault, options.Vault.ProjectsFolder);
return projectResolver.ProjectsRoot;
}
private static async Task<string?> RunRipgrepAsync(
string projectsRoot,
IReadOnlyList<ProjectFolder> projects,
IReadOnlyList<BoundMeetingProject> projects,
string keywords)
{
try
@@ -528,7 +515,7 @@ public sealed class MeetingSummaryTools
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(IReadOnlyList<ProjectFolder> projects, string keywords, bool singleProject)
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
{
try
{
@@ -584,9 +571,7 @@ public sealed class MeetingSummaryTools
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private sealed record ProjectFolder(string Name, string Path);
private sealed record ProjectFileTarget(ProjectFolder Project, string Path);
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
private sealed record FileLineEditMode(
FileLineEditKind Kind,
@@ -621,12 +606,6 @@ public sealed class MeetingSummaryTools
Insert
}
private sealed class ProjectFrontmatter
{
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
private sealed class MeetingNoteFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
@@ -39,11 +39,20 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
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 tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore));
var instructions = await instructionBuilder.BuildAsync(artifacts, cancellationToken);
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,
@@ -85,6 +94,7 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
cancellationToken: cancellationToken);
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -97,7 +107,9 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
exception,
"Meeting summary generation failed for {SummaryPath}; writing failure summary",
artifacts.SummaryPath);
return await failureWriter.WriteAsync(artifacts, exception, cancellationToken);
var result = await failureWriter.WriteAsync(artifacts, exception, options, cancellationToken);
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return result;
}
}
@@ -0,0 +1,117 @@
using DiffPlex.DiffBuilder;
using DiffPlex.DiffBuilder.Model;
using MeetingAssistant.MeetingNotes;
using System.Text;
namespace MeetingAssistant.Summary;
public sealed class SummaryAgentWriteAudit
{
private readonly MeetingSessionArtifacts artifacts;
private readonly List<FileChange> changes = [];
public SummaryAgentWriteAudit(MeetingSessionArtifacts artifacts)
{
this.artifacts = artifacts;
}
public async Task CaptureFileWriteAsync(
string path,
Func<Task> writeOperation,
CancellationToken cancellationToken = default)
{
var fullPath = Path.GetFullPath(path);
var before = File.Exists(fullPath)
? await File.ReadAllTextAsync(fullPath, cancellationToken)
: "";
await writeOperation();
var after = File.Exists(fullPath)
? await File.ReadAllTextAsync(fullPath, cancellationToken)
: "";
Capture(fullPath, before, after, cancellationToken);
}
public void Capture(
string path,
string before,
string after,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var fullPath = Path.GetFullPath(path);
if (IsOwnedArtifact(fullPath) || string.Equals(before, after, StringComparison.Ordinal))
{
return;
}
var diffLines = CreateDiffLines(before, after);
if (diffLines.Count == 0)
{
return;
}
changes.Add(new FileChange(fullPath, diffLines));
}
public async Task AppendToAssistantContextAsync(CancellationToken cancellationToken)
{
if (changes.Count == 0)
{
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken)
: "";
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
{
builder.AppendLine();
}
builder.AppendLine();
builder.AppendLine("## Summary Agent External File Changes");
builder.AppendLine();
foreach (var change in changes)
{
builder.AppendLine($"### {change.Path}");
builder.AppendLine();
builder.AppendLine("```diff");
foreach (var line in change.DiffLines)
{
builder.AppendLine(line);
}
builder.AppendLine("```");
builder.AppendLine();
}
await File.AppendAllTextAsync(artifacts.AssistantContextPath, builder.ToString(), cancellationToken);
}
private bool IsOwnedArtifact(string fullPath)
{
return IsSamePath(fullPath, artifacts.SummaryPath) ||
IsSamePath(fullPath, artifacts.AssistantContextPath);
}
private static bool IsSamePath(string first, string second)
{
return string.Equals(
Path.GetFullPath(first),
Path.GetFullPath(second),
StringComparison.OrdinalIgnoreCase);
}
private static List<string> CreateDiffLines(string before, string after)
{
var model = InlineDiffBuilder.Diff(before, after);
return model.Lines
.Where(line => line.Type is ChangeType.Deleted or ChangeType.Inserted)
.Select(line => $"{(line.Type == ChangeType.Inserted ? "+" : "-")} {line.Text}")
.ToList();
}
private sealed record FileChange(string Path, IReadOnlyList<string> DiffLines);
}