Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user