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
@@ -21,6 +21,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
private readonly TimeSpan reconnectionDelay;
private readonly LiteLlmResponsesCompactionOptions? compactionOptions;
private readonly ILogger? logger;
private int responsesRequestCount;
public LiteLlmResponsesChatClient(
Uri endpoint,
@@ -31,7 +32,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
ILogger? logger = null,
bool firstRequestIsUser = true)
: this(
new HttpClient { BaseAddress = NormalizeEndpoint(endpoint) },
apiKey,
@@ -41,7 +43,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
reconnectionAttempts,
reconnectionDelay,
compactionOptions,
logger)
logger,
firstRequestIsUser)
{
}
@@ -54,7 +57,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
int reconnectionAttempts,
TimeSpan reconnectionDelay,
LiteLlmResponsesCompactionOptions? compactionOptions = null,
ILogger? logger = null)
ILogger? logger = null,
bool firstRequestIsUser = true)
{
this.httpClient = httpClient;
this.httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
@@ -65,6 +69,7 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
this.reconnectionDelay = reconnectionDelay > TimeSpan.Zero ? reconnectionDelay : TimeSpan.Zero;
this.compactionOptions = compactionOptions;
this.logger = logger;
responsesRequestCount = firstRequestIsUser ? 0 : 1;
}
public void Dispose()
@@ -81,6 +86,8 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
var responseJson = await PostWithRetryAsync(payload, cancellationToken).ConfigureAwait(false);
var response = ParseResponseJson(responseJson);
LogResponseDiagnostics(responseJson, response);
ThrowIfResponseHasNoContent(responseJson, response);
LogResponseUsage(response.Usage);
return response;
}
@@ -225,12 +232,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
}
};
using var content = new StringContent(request.ToJsonString(JsonOptions), Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(
NormalizeRelativePath(compactionOptions.CompactPath),
content,
cancellationToken)
.ConfigureAwait(false);
using var requestMessage = CreateJsonRequest(
NormalizeRelativePath(compactionOptions.CompactPath),
request.ToJsonString(JsonOptions),
"agent");
using var response = await httpClient.SendAsync(requestMessage, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@@ -319,6 +325,11 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
};
}
if (options?.MaxOutputTokens is > 0)
{
payload["max_output_tokens"] = options.MaxOutputTokens.Value;
}
var tools = CreateTools(options?.Tools);
if (tools.Count > 0)
{
@@ -338,8 +349,13 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
{
try
{
using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync("responses", content, cancellationToken).ConfigureAwait(false);
var initiator = NextInitiator();
LogRequestDiagnostics(payload, initiator, attempt);
using var request = CreateJsonRequest(
"responses",
payloadJson,
initiator);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var responseJson = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
@@ -367,6 +383,28 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
throw lastException ?? new InvalidOperationException("LiteLLM Responses request failed.");
}
private HttpRequestMessage CreateJsonRequest(
string requestUri,
string payloadJson,
string? initiator = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
{
Content = new StringContent(payloadJson, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(initiator))
{
request.Headers.TryAddWithoutValidation("X-Initiator", initiator);
}
return request;
}
private string NextInitiator()
{
return Interlocked.Increment(ref responsesRequestCount) == 1 ? "user" : "agent";
}
private async Task DelayBeforeRetryAsync(CancellationToken cancellationToken)
{
if (reconnectionDelay > TimeSpan.Zero)
@@ -423,6 +461,110 @@ public sealed class LiteLlmResponsesChatClient : IChatClient
contextWindow);
}
private void LogRequestDiagnostics(JsonObject payload, string initiator, int attempt)
{
if (logger is null)
{
return;
}
var inputCount = payload.TryGetPropertyValue("input", out var input) && input is JsonArray inputArray
? inputArray.Count
: 0;
var tools = payload.TryGetPropertyValue("tools", out var toolsNode) && toolsNode is JsonArray toolsArray
? toolsArray
: [];
var toolNames = tools
.OfType<JsonObject>()
.Select(tool => tool.TryGetPropertyValue("name", out var name) ? name?.GetValue<string>() : null)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
var instructionsPreview = payload.TryGetPropertyValue("instructions", out var instructionsNode)
? Truncate(instructionsNode?.GetValue<string>() ?? "", maxLength: 500)
: "";
logger.LogInformation(
"LiteLLM Responses request: initiator={Initiator}, attempt={Attempt}, inputItems={InputItems}, tools={ToolCount}, toolNames={ToolNames}, instructionsPreview={InstructionsPreview}",
initiator,
attempt + 1,
inputCount,
toolNames.Length,
string.Join(", ", toolNames),
instructionsPreview);
}
private void LogResponseDiagnostics(string responseJson, ChatResponse response)
{
if (logger is null)
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
var parsedTypes = response.Messages
.SelectMany(message => message.Contents)
.Select(content => content.GetType().Name)
.ToArray();
var functionCalls = response.Messages
.SelectMany(message => message.Contents)
.OfType<FunctionCallContent>()
.Select(call => call.Name)
.ToArray();
logger.LogInformation(
"LiteLLM Responses response: responseId={ResponseId}, outputTypes={OutputTypes}, parsedContents={ParsedContents}, functionCalls={FunctionCalls}, textLength={TextLength}",
response.ResponseId,
string.Join(", ", outputTypes),
string.Join(", ", parsedTypes),
string.Join(", ", functionCalls),
response.Text?.Length ?? 0);
if (parsedTypes.Length == 0)
{
logger.LogWarning(
"LiteLLM Responses response contained no parseable message text or function calls. Raw response preview: {ResponsePreview}",
Truncate(responseJson, maxLength: 6000));
}
}
private static void ThrowIfResponseHasNoContent(string responseJson, ChatResponse response)
{
if (response.Messages.SelectMany(message => message.Contents).Any())
{
return;
}
var outputTypes = GetOutputItemTypes(responseJson);
throw new InvalidOperationException(
"LiteLLM Responses returned no parseable message text or function calls. " +
$"ResponseId={response.ResponseId ?? "<none>"}, outputTypes=[{string.Join(", ", outputTypes)}].");
}
private static string[] GetOutputItemTypes(string responseJson)
{
try
{
using var document = JsonDocument.Parse(responseJson);
return document.RootElement.TryGetProperty("output", out var output) && output.ValueKind == JsonValueKind.Array
? output
.EnumerateArray()
.Select(item => GetString(item, "type") ?? item.ValueKind.ToString())
.ToArray()
: [];
}
catch (JsonException)
{
return ["<invalid-json>"];
}
}
private static string Truncate(string value, int maxLength)
{
return value.Length <= maxLength
? value
: value[..maxLength] + "...";
}
private static void AddInputItem(JsonArray input, StringBuilder instructions, ChatMessage message)
{
var role = message.Role.Value;
@@ -1,5 +1,6 @@
using MeetingAssistant.MeetingNotes;
using Microsoft.Extensions.Options;
using YamlDotNet.Serialization;
namespace MeetingAssistant.Summary;
@@ -12,14 +13,15 @@ public interface IMeetingSummaryArtifactResolver
Task<MeetingSessionArtifacts?> ResolveBySummaryPathAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
return ResolveBySummaryPathAsync(summaryPath, cancellationToken);
}
CancellationToken cancellationToken);
}
public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactResolver
{
private static readonly IDeserializer YamlDeserializer = new DeserializerBuilder()
.IgnoreUnmatchedProperties()
.Build();
private readonly MeetingAssistantOptions options;
private readonly IMeetingNoteStore meetingNoteStore;
@@ -49,6 +51,14 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
return null;
}
var summaryArtifacts = await ResolveFromSummaryFrontmatterAsync(
requestedSummaryPath,
cancellationToken);
if (summaryArtifacts is not null)
{
return summaryArtifacts;
}
var meetingNotesFolder = VaultPath.Resolve(options.Vault, options.Vault.MeetingNotesFolder);
if (!Directory.Exists(meetingNotesFolder))
{
@@ -64,16 +74,77 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
continue;
}
return new MeetingSessionArtifacts(
return TryCreateArtifactsFromLinks(
note.Path,
ResolveNoteLink(note.Frontmatter.Transcript, note.Path),
ResolveNoteLink(note.Frontmatter.AssistantContext, note.Path),
requestedSummaryPath);
requestedSummaryPath,
note.Frontmatter.Transcript,
note.Frontmatter.AssistantContext);
}
return null;
}
private static async Task<MeetingSessionArtifacts?> ResolveFromSummaryFrontmatterAsync(
string requestedSummaryPath,
CancellationToken cancellationToken)
{
if (!File.Exists(requestedSummaryPath))
{
return null;
}
var content = await File.ReadAllTextAsync(requestedSummaryPath, cancellationToken);
var (frontmatter, _) = MeetingArtifactFrontmatterRenderer.Split(content);
if (string.IsNullOrWhiteSpace(frontmatter))
{
return null;
}
SummaryArtifactLinks? links;
try
{
links = YamlDeserializer.Deserialize<SummaryArtifactLinks>(frontmatter);
}
catch (YamlDotNet.Core.YamlException)
{
return null;
}
if (string.IsNullOrWhiteSpace(links?.Meeting) ||
string.IsNullOrWhiteSpace(links.Transcript) ||
string.IsNullOrWhiteSpace(links.AssistantContext))
{
return null;
}
return TryCreateArtifactsFromLinks(
requestedSummaryPath,
requestedSummaryPath,
links.Transcript,
links.AssistantContext,
links.Meeting);
}
private static MeetingSessionArtifacts? TryCreateArtifactsFromLinks(
string sourceNotePath,
string requestedSummaryPath,
string transcriptLink,
string assistantContextLink,
string? meetingLink = null)
{
var assistantContextPath = ResolveNoteLink(assistantContextLink, sourceNotePath);
if (string.IsNullOrWhiteSpace(assistantContextPath) || !File.Exists(assistantContextPath))
{
return null;
}
return new MeetingSessionArtifacts(
meetingLink is null ? sourceNotePath : ResolveNoteLink(meetingLink, sourceNotePath),
ResolveNoteLink(transcriptLink, sourceNotePath),
assistantContextPath,
requestedSummaryPath);
}
private static string? ResolveRequestedSummaryPath(
string summaryPath,
MeetingAssistantOptions options)
@@ -141,4 +212,16 @@ public sealed class MeetingSummaryArtifactResolver : IMeetingSummaryArtifactReso
var normalizedPath = Path.GetFullPath(path);
return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase);
}
private sealed class SummaryArtifactLinks
{
[YamlMember(Alias = "meeting")]
public string? Meeting { get; set; }
[YamlMember(Alias = "transcript")]
public string? Transcript { get; set; }
[YamlMember(Alias = "assistant_context")]
public string? AssistantContext { get; set; }
}
}
@@ -14,6 +14,9 @@ public sealed class MeetingSummaryInstructionBuilder : IMeetingSummaryInstructio
Use read_meetingnote to inspect frontmatter such as title, attendees, projects, start_time, and end_time.
Use read_context and write_context as your own meeting notebook. Its frontmatter may include agenda from the calendar appointment. Record useful internal notes, missing context, requests for future tools, suggested improvements, and relevant context discovered from other sources. Keep user-facing summary content in the summary note.
Use add_dictation_word when project context, user notes, or transcript evidence show that a domain term, acronym, name, or unusual word is likely to be repeatedly mistranscribed. Add only the canonical spelling, one term at a time.
Use add_attendee and remove_attendee to sharpen the meeting attendees list from clear transcript evidence and screenshot OCR participant evidence. Treat OCR that says visible people are a partial screenshot result as incomplete evidence; do not remove attendees solely because they are absent from a partial screenshot.
Use override_speaker only when you are very certain that a transcript speaker label belongs to a named person, for example from user notes, OCR evidence with a matching timestamp, or very clear context cues. Provide the exact speaker label from the transcript and the replacement speaker name. If the replacement speaker already exists in the transcript, use merge=true only when you are certain both speaker labels are the same identity; otherwise do not merge them.
Use delete_identity only when you are certain that an existing speaker identity was wrongfully matched. Provide the exact identity name currently used in the transcript.
After writing the summary, update existing project files when the meeting produced durable project knowledge, decisions, next steps, or context.
Use list_projects first to see which projects are bound to this meeting. Use search and read_projectfile before changing existing project files.
The summary note should contain concise sections for summary, decisions, open questions, and next steps.
@@ -0,0 +1,97 @@
using MeetingAssistant.MeetingNotes;
namespace MeetingAssistant.Summary;
public interface IMeetingSummaryRetryRunner
{
Task<MeetingSummaryRetryTriggerResult?> TriggerAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken);
}
public sealed record MeetingSummaryRetryTriggerResult(
string SummaryPath,
string AssistantContextPath);
public sealed class MeetingSummaryRetryRunner : IMeetingSummaryRetryRunner
{
private readonly IMeetingSummaryArtifactResolver artifactResolver;
private readonly IMeetingSummaryPipeline summaryPipeline;
private readonly IMeetingArtifactStore artifactStore;
private readonly ILogger<MeetingSummaryRetryRunner> logger;
public MeetingSummaryRetryRunner(
IMeetingSummaryArtifactResolver artifactResolver,
IMeetingSummaryPipeline summaryPipeline,
IMeetingArtifactStore artifactStore,
ILogger<MeetingSummaryRetryRunner> logger)
{
this.artifactResolver = artifactResolver;
this.summaryPipeline = summaryPipeline;
this.artifactStore = artifactStore;
this.logger = logger;
}
public async Task<MeetingSummaryRetryTriggerResult?> TriggerAsync(
string summaryPath,
MeetingAssistantOptions options,
CancellationToken cancellationToken)
{
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(
summaryPath,
options,
cancellationToken);
if (artifacts is null)
{
return null;
}
_ = Task.Run(() => RunRetryAsync(artifacts, options), CancellationToken.None);
return new MeetingSummaryRetryTriggerResult(
artifacts.SummaryPath,
artifacts.AssistantContextPath);
}
private async Task RunRetryAsync(
MeetingSessionArtifacts artifacts,
MeetingAssistantOptions options)
{
try
{
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
AssistantContextState.Summarizing,
CancellationToken.None);
var result = await summaryPipeline.RunAsync(
artifacts,
options,
CancellationToken.None);
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
CancellationToken.None);
}
catch (Exception exception)
{
logger.LogError(
exception,
"Summary retry failed unexpectedly for {SummaryPath}",
artifacts.SummaryPath);
try
{
await artifactStore.UpdateAssistantContextStateAsync(
artifacts,
AssistantContextState.Error,
CancellationToken.None);
}
catch (Exception stateException)
{
logger.LogWarning(
stateException,
"Could not mark assistant context error after summary retry failed for {SummaryPath}",
artifacts.SummaryPath);
}
}
}
}
+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();
@@ -51,7 +51,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
var agentOptions = options.Agent;
var key = ResolveApiKey(agentOptions);
var writeAudit = new SummaryAgentWriteAudit(artifacts);
var tools = CreateTools(new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit));
var meetingTools = new MeetingSummaryTools(artifacts, options, dictationWordStore, writeAudit);
var tools = CreateTools(meetingTools);
var instructions = await instructionBuilder.BuildAsync(artifacts, options, cancellationToken);
using var compactionSummaryClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
@@ -62,7 +63,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions: null,
logger);
logger,
firstRequestIsUser: false);
var compactionOptions = CreateCompactionOptions(agentOptions, compactionSummaryClient);
using var chatClient = new LiteLlmResponsesChatClient(
new Uri(agentOptions.Endpoint),
@@ -73,7 +75,8 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
agentOptions.ReconnectionAttempts,
agentOptions.ReconnectionDelay,
compactionOptions,
logger);
logger,
firstRequestIsUser: false);
var agent = chatClient
.AsBuilder()
.UseFunctionInvocation()
@@ -93,6 +96,10 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
session: null,
options: new ChatClientAgentRunOptions(CreateChatOptions(agentOptions)),
cancellationToken: cancellationToken);
if (!meetingTools.SummaryWasWritten)
{
throw CreateMissingSummaryWriteException(response);
}
await writeAudit.AppendToAssistantContextAsync(cancellationToken);
return new MeetingSummaryRunResult(artifacts.SummaryPath, response.Text);
@@ -145,6 +152,22 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
tools.AddDictationWord,
"add_dictation_word",
"Add one domain term, name, acronym, or unusual word to the transcription dictionary with deduplication."),
AIFunctionFactory.Create(
tools.AddAttendee,
"add_attendee",
"Add one attendee to the current meeting note frontmatter when transcript or OCR evidence clearly shows they attended."),
AIFunctionFactory.Create(
tools.RemoveAttendee,
"remove_attendee",
"Remove one attendee from the current meeting note frontmatter when evidence clearly shows they did not attend. Do not remove attendees solely because a screenshot contains only partial participant evidence."),
AIFunctionFactory.Create(
tools.OverrideSpeaker,
"override_speaker",
"Replace a transcript speaker label with a named speaker only when the evidence is very certain. If the replacement speaker already exists, set merge=true only when both labels are certainly the same identity."),
AIFunctionFactory.Create(
tools.DeleteIdentity,
"delete_identity",
"Remove a wrongfully matched speaker identity and relabel transcript mentions to Removed-n only when the evidence is certain."),
AIFunctionFactory.Create(
tools.WriteSummary,
"write_summary",
@@ -172,11 +195,21 @@ public sealed class OpenAiMeetingSummaryAgentPipeline : IMeetingSummaryPipeline
];
}
private static InvalidOperationException CreateMissingSummaryWriteException(AgentResponse response)
{
var message = string.IsNullOrWhiteSpace(response.Text)
? "Summary agent completed without calling write_summary."
: $"Summary agent completed without calling write_summary. Response: {response.Text}";
return new InvalidOperationException(message);
}
private static ChatOptions CreateChatOptions(AgentOptions options)
{
var chatOptions = new ChatOptions
{
ModelId = options.Model,
MaxOutputTokens = options.MaxOutputTokens,
AllowMultipleToolCalls = true,
ToolMode = ChatToolMode.Auto
};
@@ -60,10 +60,12 @@ public sealed class SummaryAgentWriteAudit
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(artifacts.AssistantContextPath)!);
var existing = File.Exists(artifacts.AssistantContextPath)
? await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken)
: "";
if (!File.Exists(artifacts.AssistantContextPath))
{
return;
}
var existing = await File.ReadAllTextAsync(artifacts.AssistantContextPath, cancellationToken);
var builder = new StringBuilder();
if (!string.IsNullOrEmpty(existing) && !existing.EndsWith(Environment.NewLine, StringComparison.Ordinal))
{