Public Access
Add past project meeting summary tools
PR and Push Build/Test / build-and-test (push) Failing after 10m56s
PR and Push Build/Test / build-and-test (push) Failing after 10m56s
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user