Add past project meeting summary tools
PR and Push Build/Test / build-and-test (push) Failing after 10m56s

This commit is contained in:
2026-05-29 10:30:48 +02:00
parent 9d2153484a
commit 626640e26b
16 changed files with 695 additions and 109 deletions
+268 -82
View File
@@ -1,8 +1,6 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using System.Diagnostics;
using System.Text.Json;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
@@ -311,6 +309,45 @@ public sealed class MeetingSummaryTools
return ReadLines(await File.ReadAllTextAsync(filePath), @from, to);
}
public async Task<string> ListPastProjectMeetings(
string[]? projects = null,
int page = 1,
int page_size = 20)
{
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
if (scope.Refusal is not null)
{
return scope.Refusal;
}
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
var effectivePageSize = Math.Clamp(page_size, 1, 100);
var effectivePage = Math.Max(1, page);
var totalPages = Math.Max(1, (int)Math.Ceiling(summaries.Count / (double)effectivePageSize));
var pageItems = summaries
.Skip((effectivePage - 1) * effectivePageSize)
.Take(effectivePageSize)
.ToList();
var lines = new List<string>
{
$"page {Math.Min(effectivePage, totalPages)}/{totalPages}, page_size {effectivePageSize}, total {summaries.Count}"
};
lines.AddRange(pageItems.Select(summary =>
$"{summary.ToolPath} | {FormatSummaryStartTime(summary.StartTime)} | {summary.Title} | projects: {string.Join(", ", summary.Projects)}"));
return string.Join('\n', lines);
}
public async Task<string> ReadPastProjectMeetingSummary(string path, int? @from = null, int? to = null)
{
var summary = await ResolvePastSummaryAsync(path);
if (summary.Refusal is not null)
{
return summary.Refusal;
}
return ReadLines(await File.ReadAllTextAsync(summary.Summary!.Path), @from, to);
}
public async Task<string> WriteProjectFile(
string project,
string path,
@@ -354,17 +391,15 @@ public sealed class MeetingSummaryTools
return "";
}
var selectedProjects = await GetSearchProjectsAsync(projects);
if (selectedProjects.Count == 0)
var scope = await ResolveCurrentMeetingProjectScopeAsync(projects);
if (scope.Refusal is not null)
{
return "";
return scope.Refusal;
}
var projectsRoot = GetProjectsRoot();
var result = await RunRipgrepAsync(projectsRoot, selectedProjects, keywords);
return result is not null
? FormatRipgrepJson(result, selectedProjects.Count == 1)
: SearchWithRegexFallback(selectedProjects, keywords, selectedProjects.Count == 1);
var selectedProjects = await GetExistingProjectsInScopeAsync(scope.ProjectNames);
var summaries = await GetScopedPastSummariesAsync(scope.ProjectNames);
return SearchSources(selectedProjects, summaries, keywords, selectedProjects.Count == 1);
}
private static async Task<string> ReadIfExistsAsync(string path, int? from = null, int? to = null)
@@ -462,19 +497,64 @@ public sealed class MeetingSummaryTools
: value;
}
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
private async Task<ProjectScope> ResolveCurrentMeetingProjectScopeAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();
var currentProjects = await ReadCurrentMeetingProjectNamesAsync();
if (currentProjects.Count == 0)
{
return new ProjectScope(new HashSet<string>(StringComparer.OrdinalIgnoreCase));
}
if (projects is null || projects.Length == 0)
{
return boundProjects;
return new ProjectScope(currentProjects.ToHashSet(StringComparer.OrdinalIgnoreCase));
}
var requested = projects
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var outOfScope = requested
.Where(project => !currentProjects.Contains(project))
.Order(StringComparer.OrdinalIgnoreCase)
.ToList();
if (outOfScope.Count > 0)
{
return new ProjectScope(
new HashSet<string>(StringComparer.OrdinalIgnoreCase),
$"Refused: project(s) not assigned to current meeting: {string.Join(", ", outOfScope)}.");
}
return new ProjectScope(requested.ToHashSet(StringComparer.OrdinalIgnoreCase));
}
private async Task<HashSet<string>> ReadCurrentMeetingProjectNamesAsync()
{
var note = await ReadMeetingNoteAsync();
return note.Frontmatter.Projects
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return boundProjects
.Where(project => requested.Contains(project.Name))
}
private async Task<List<BoundMeetingProject>> GetExistingProjectsInScopeAsync(IReadOnlySet<string> projectNames)
{
if (projectNames.Count == 0)
{
return [];
}
var projectsRoot = GetProjectsRoot();
if (!Directory.Exists(projectsRoot))
{
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();
}
@@ -681,74 +761,11 @@ public sealed class MeetingSummaryTools
return projectResolver.ProjectsRoot;
}
private static async Task<string?> RunRipgrepAsync(
string projectsRoot,
private static string SearchSources(
IReadOnlyList<BoundMeetingProject> projects,
string keywords)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "rg",
WorkingDirectory = projectsRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
startInfo.ArgumentList.Add("--json");
startInfo.ArgumentList.Add("--line-number");
startInfo.ArgumentList.Add("--color");
startInfo.ArgumentList.Add("never");
startInfo.ArgumentList.Add("--");
startInfo.ArgumentList.Add(keywords);
foreach (var project in projects)
{
startInfo.ArgumentList.Add(project.Name);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return null;
}
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
return process.ExitCode is 0 or 1 ? output : null;
}
catch
{
return null;
}
}
private static string FormatRipgrepJson(string output, bool singleProject)
{
var matches = new List<string>();
using var reader = new StringReader(output);
string? line;
while ((line = reader.ReadLine()) is not null)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
if (!root.TryGetProperty("type", out var type) ||
type.GetString() != "match" ||
!root.TryGetProperty("data", out var data))
{
continue;
}
var path = data.GetProperty("path").GetProperty("text").GetString() ?? "";
var lineNumber = data.GetProperty("line_number").GetInt32();
var text = data.GetProperty("lines").GetProperty("text").GetString() ?? "";
matches.Add($"{FormatSearchPath(path, singleProject)}:{lineNumber} {text.TrimEnd('\r', '\n')}");
}
return string.Join('\n', matches);
}
private static string SearchWithRegexFallback(IReadOnlyList<BoundMeetingProject> projects, string keywords, bool singleProject)
IReadOnlyList<PastMeetingSummary> summaries,
string keywords,
bool singleProject)
{
try
{
@@ -770,6 +787,11 @@ public sealed class MeetingSummaryTools
}
}
foreach (var summary in summaries)
{
AddFileMatches(matches, regex, summary.Path, $"past-meetings/{summary.ToolPath}");
}
return string.Join('\n', matches);
}
catch
@@ -778,6 +800,22 @@ public sealed class MeetingSummaryTools
}
}
private static void AddFileMatches(
List<string> matches,
System.Text.RegularExpressions.Regex regex,
string file,
string formattedPath)
{
var lines = File.ReadAllLines(file);
for (var index = 0; index < lines.Length; index++)
{
if (regex.IsMatch(lines[index]))
{
matches.Add($"{ToToolPath(formattedPath)}:{index + 1} {lines[index]}");
}
}
}
private static string FormatSearchPath(string path, bool singleProject)
{
var formatted = ToToolPath(path);
@@ -804,8 +842,136 @@ public sealed class MeetingSummaryTools
.Replace(Path.AltDirectorySeparatorChar.ToString(), "/", StringComparison.Ordinal);
}
private async Task<List<PastMeetingSummary>> GetScopedPastSummariesAsync(IReadOnlySet<string> projectNames)
{
if (projectNames.Count == 0)
{
return [];
}
var summariesRoot = GetSummariesRoot();
if (!Directory.Exists(summariesRoot))
{
return [];
}
var summaries = new List<PastMeetingSummary>();
foreach (var file in Directory.EnumerateFiles(summariesRoot, "*.md", SearchOption.TopDirectoryOnly))
{
var summary = await TryReadPastSummaryAsync(file, summariesRoot);
if (summary is null ||
IsSamePath(summary.Path, artifacts.SummaryPath) ||
!summary.Projects.Any(projectNames.Contains))
{
continue;
}
summaries.Add(summary);
}
return summaries
.OrderByDescending(summary => summary.StartTime)
.ThenByDescending(summary => summary.ToolPath, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private async Task<PastMeetingSummaryResolution> ResolvePastSummaryAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || Path.IsPathRooted(path))
{
return PastMeetingSummaryResolution.Refused("Refused: summary path must be relative to the configured summaries folder.");
}
var scope = await ResolveCurrentMeetingProjectScopeAsync(null);
if (scope.Refusal is not null)
{
return PastMeetingSummaryResolution.Refused(scope.Refusal);
}
var summariesRoot = GetSummariesRoot();
var fullPath = Path.GetFullPath(Path.Combine(summariesRoot, path));
if (!IsWithinDirectory(summariesRoot, fullPath) || !File.Exists(fullPath))
{
return PastMeetingSummaryResolution.Refused("Refused: summary file does not exist under the configured summaries folder.");
}
if (IsSamePath(fullPath, artifacts.SummaryPath))
{
return PastMeetingSummaryResolution.Refused("Refused: read_past_project_meeting_summary cannot read the current meeting summary; use write_summary only for the current meeting summary.");
}
var summary = await TryReadPastSummaryAsync(fullPath, summariesRoot);
if (summary is null)
{
return PastMeetingSummaryResolution.Refused("Refused: file is not a summary note with project frontmatter.");
}
if (!summary.Projects.Any(scope.ProjectNames.Contains))
{
return PastMeetingSummaryResolution.Refused("Refused: summary file is not assigned to a project in the current meeting scope.");
}
return new PastMeetingSummaryResolution(summary);
}
private async Task<PastMeetingSummary?> TryReadPastSummaryAsync(string path, string summariesRoot)
{
var content = await File.ReadAllTextAsync(path);
var document = MarkdownDocumentParser.SplitOptional(content);
if (!document.HasFrontmatter)
{
return null;
}
var frontmatter = YamlDeserializer.Deserialize<SummaryFrontmatterYaml>(document.Frontmatter)
?? new SummaryFrontmatterYaml();
var projects = (frontmatter.Projects ?? [])
.Where(project => !string.IsNullOrWhiteSpace(project))
.Select(project => project.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (projects.Count == 0)
{
return null;
}
return new PastMeetingSummary(
path,
ToToolPath(Path.GetRelativePath(summariesRoot, path)),
string.IsNullOrWhiteSpace(frontmatter.Title) ? Path.GetFileNameWithoutExtension(path) : frontmatter.Title.Trim(),
ParseDateTime(frontmatter.StartTime),
projects);
}
private string GetSummariesRoot()
{
return VaultPath.Resolve(options.Vault, options.Vault.SummariesFolder);
}
private static string FormatSummaryStartTime(DateTimeOffset? startTime)
{
return startTime?.ToString("O") ?? "";
}
private sealed record ProjectFileTarget(BoundMeetingProject Project, string Path);
private sealed record ProjectScope(IReadOnlySet<string> ProjectNames, string? Refusal = null);
private sealed record PastMeetingSummary(
string Path,
string ToolPath,
string Title,
DateTimeOffset? StartTime,
IReadOnlyList<string> Projects);
private sealed record PastMeetingSummaryResolution(PastMeetingSummary? Summary, string? Refusal = null)
{
public static PastMeetingSummaryResolution Refused(string message)
{
return new PastMeetingSummaryResolution(null, message);
}
}
private sealed record FileLineEditMode(
FileLineEditKind Kind,
int? From = null,
@@ -872,10 +1038,30 @@ public sealed class MeetingSummaryTools
public string? Summary { get; set; }
}
private sealed class SummaryFrontmatterYaml
{
[YamlDotNet.Serialization.YamlMember(Alias = "title")]
public string? Title { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "start_time")]
public string? StartTime { get; set; }
[YamlDotNet.Serialization.YamlMember(Alias = "projects")]
public List<string>? Projects { get; set; }
}
private static DateTimeOffset? ParseDateTime(string? value)
{
return DateTimeOffset.TryParse(value, out var parsed)
? parsed
: null;
}
private static bool IsSamePath(string first, string second)
{
return string.Equals(
Path.GetFullPath(first),
Path.GetFullPath(second),
StringComparison.OrdinalIgnoreCase);
}
}