Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s

This commit is contained in:
2026-05-27 23:11:21 +02:00
parent c12febe029
commit 7777b349a5
37 changed files with 3190 additions and 121 deletions
+179 -4
View File
@@ -1,4 +1,5 @@
using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Speakers;
using MeetingAssistant.Transcription;
using System.Diagnostics;
using System.Text.Json;
@@ -94,6 +95,117 @@ public sealed class MeetingSummaryTools
return $"Added '{word.Trim()}'. Dictionary now contains {words.Count} word(s).";
}
public async Task<string> AddAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var displayName = attendee.Trim();
var normalized = MeetingAttendeeNames.NormalizeDisplayName(displayName);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
if (note.Frontmatter.Attendees.Any(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase)))
{
return $"Attendee {normalized} already exists.";
}
note.Frontmatter.Attendees.Add(displayName);
await WriteMeetingNoteAsync(note);
return $"Added attendee {displayName}.";
}
public async Task<string> RemoveAttendee(string attendee)
{
if (string.IsNullOrWhiteSpace(attendee))
{
return "Refused: attendee must not be empty.";
}
var normalized = MeetingAttendeeNames.NormalizeDisplayName(attendee);
if (string.IsNullOrWhiteSpace(normalized))
{
return "Refused: attendee must not be empty.";
}
var note = await ReadMeetingNoteAsync();
var removed = note.Frontmatter.Attendees.RemoveAll(existing => string.Equals(
MeetingAttendeeNames.NormalizeDisplayName(existing),
normalized,
StringComparison.OrdinalIgnoreCase));
if (removed == 0)
{
return $"Attendee {normalized} was not present.";
}
await WriteMeetingNoteAsync(note);
return $"Removed attendee {normalized}.";
}
public async Task<string> OverrideSpeaker(string speaker, string replacement, bool merge = false)
{
if (string.IsNullOrWhiteSpace(speaker) || string.IsNullOrWhiteSpace(replacement))
{
return "Refused: speaker and replacement must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var speakerOverride = new SpeakerOverride(speaker.Trim(), replacement.Trim(), merge);
if (string.Equals(speakerOverride.From, speakerOverride.To, StringComparison.OrdinalIgnoreCase))
{
return "Refused: speaker and replacement must be different.";
}
if (!merge && await SpeakerOverrideArtifacts.TranscriptContainsSpeakerAsync(
artifacts.TranscriptPath,
speakerOverride.To,
CancellationToken.None))
{
return $"Refused: transcript already contains speaker {speakerOverride.To}. Call override_speaker with merge=true only when you are certain both speakers are the same identity.";
}
await SpeakerOverrideArtifacts.ApplyToTranscriptAsync(artifacts.TranscriptPath, speakerOverride);
await SpeakerOverrideArtifacts.AppendToAssistantContextAsync(artifacts.AssistantContextPath, speakerOverride);
return merge
? $"Merged speaker {speakerOverride.From} into {speakerOverride.To}."
: $"Overrode speaker {speakerOverride.From} with {speakerOverride.To}.";
}
public async Task<string> DeleteIdentity(string identity)
{
if (string.IsNullOrWhiteSpace(identity))
{
return "Refused: identity must not be empty.";
}
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var deletion = await SpeakerOverrideArtifacts.ApplyDeletionToTranscriptAsync(
artifacts.TranscriptPath,
identity,
CancellationToken.None);
await SpeakerOverrideArtifacts.AppendIdentityDeletionToAssistantContextAsync(
artifacts.AssistantContextPath,
deletion,
CancellationToken.None);
return $"Deleted identity {deletion.Identity} and relabeled transcript speaker mentions to {deletion.Replacement}.";
}
public async Task<string> WriteSummary(string markdown, string? title = null)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.SummaryPath)!);
@@ -109,9 +221,12 @@ public sealed class MeetingSummaryTools
await File.WriteAllTextAsync(
artifacts.SummaryPath,
MeetingArtifactFrontmatterRenderer.Render(frontmatter, markdown));
SummaryWasWritten = true;
return artifacts.SummaryPath;
}
public bool SummaryWasWritten { get; private set; }
public async Task<string> WriteContext(
string content,
int? @from = null,
@@ -124,10 +239,12 @@ public sealed class MeetingSummaryTools
return "Refused: supply either both from and to for replacement, insert for insertion, or no line arguments for overwrite.";
}
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? await File.ReadAllTextAsync(artifacts.AssistantContextPath)
: "";
if (!File.Exists(artifacts.AssistantContextPath))
{
return $"Refused: assistant context file does not exist at {artifacts.AssistantContextPath}.";
}
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath);
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(existing);
var updatedBody = ApplyLineEdit(body, StripAccidentalFrontmatter(content), editMode);
var updated = string.IsNullOrWhiteSpace(frontmatter)
@@ -267,6 +384,64 @@ public sealed class MeetingSummaryTools
return new MeetingNote(artifacts.MeetingNotePath, frontmatter, await ReadUserNotes());
}
private async Task WriteMeetingNoteAsync(MeetingNote note)
{
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.MeetingNotePath)!);
await File.WriteAllTextAsync(
artifacts.MeetingNotePath,
RenderMeetingNote(note.Frontmatter, note.UserNotes));
}
private static string RenderMeetingNote(MeetingNoteFrontmatter frontmatter, string userNotes)
{
var lines = new List<string>
{
"---",
$"title: {EscapeScalar(frontmatter.Title)}",
$"start_time: {EscapeNullableDateTime(frontmatter.StartTime)}",
$"end_time: {EscapeNullableDateTime(frontmatter.EndTime)}",
"attendees:"
};
lines.AddRange(frontmatter.Attendees.Select(attendee => $"- {EscapeListItem(attendee)}"));
lines.Add("projects:");
lines.AddRange(frontmatter.Projects.Select(project => $"- {EscapeListItem(project)}"));
lines.Add($"transcript: {EscapeQuoted(frontmatter.Transcript)}");
lines.Add($"assistant_context: {EscapeQuoted(frontmatter.AssistantContext)}");
lines.Add($"summary: {EscapeQuoted(frontmatter.Summary)}");
lines.Add("---");
lines.Add("");
lines.Add(userNotes);
return string.Join(Environment.NewLine, lines);
}
private static string EscapeScalar(string value)
{
return string.IsNullOrWhiteSpace(value) ? "\"\"" : EscapeListItem(value);
}
private static string EscapeQuoted(string value)
{
return $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
}
private static string EscapeNullableDateTime(DateTimeOffset? value)
{
return value is null ? "\"\"" : EscapeQuoted(value.Value.ToString("O"));
}
private static string EscapeListItem(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return "\"\"";
}
return value.Contains(':', StringComparison.Ordinal) || value.StartsWith("[", StringComparison.Ordinal)
? EscapeQuoted(value)
: value;
}
private async Task<List<BoundMeetingProject>> GetSearchProjectsAsync(string[]? projects)
{
var boundProjects = await GetBoundProjectsAsync();