Public Access
Archive summary refinements and profile switching
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
PR and Push Build/Test / build-and-test (push) Successful in 6m37s
This commit is contained in:
@@ -45,6 +45,7 @@ public sealed class ScreenshotOcrOptions
|
||||
This screenshot was captured during a meeting. Extract information that is useful for meeting notes.
|
||||
|
||||
Identify who appears to be talking, who appears to be presenting, and what is being presented when visible.
|
||||
If people or participant tiles are visible, state whether it is clear from the screenshot exactly who is in the meeting or whether the visible people are only a partial participant result.
|
||||
If the screenshot contains slides, capture the visible text in clean markdown.
|
||||
If the screenshot contains a diagram, convert it to Mermaid when possible and preserve labels accurately.
|
||||
If it contains another kind of shared screen or scene, describe the scene and the relevant visible information.
|
||||
@@ -83,7 +84,7 @@ public sealed class HotkeyOptions
|
||||
{
|
||||
public string Toggle { get; set; } = "Ctrl+Alt+M";
|
||||
|
||||
public string Abort { get; set; } = "Ctrl+Alt+D";
|
||||
public string Abort { get; set; } = "Ctrl+Alt+Z";
|
||||
}
|
||||
|
||||
public sealed class VaultOptions
|
||||
@@ -309,7 +310,7 @@ public sealed class AgentOptions
|
||||
|
||||
public string KeyEnv { get; set; } = "LITELLM_API_KEY";
|
||||
|
||||
public string Model { get; set; } = "copilot-gpt-5.5";
|
||||
public string Model { get; set; } = "chatgpt/gpt-5.5";
|
||||
|
||||
public bool EnableThinking { get; set; } = true;
|
||||
|
||||
|
||||
+18
-21
@@ -57,6 +57,7 @@ builder.Services.AddSingleton<IMeetingSummaryArtifactResolver, MeetingSummaryArt
|
||||
builder.Services.AddSingleton<IMeetingSummaryFailureWriter, MeetingSummaryFailureWriter>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryInstructionBuilder, MeetingSummaryInstructionBuilder>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryPipeline, OpenAiMeetingSummaryAgentPipeline>();
|
||||
builder.Services.AddSingleton<IMeetingSummaryRetryRunner, MeetingSummaryRetryRunner>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowRulesProvider, FileMeetingWorkflowRulesProvider>();
|
||||
builder.Services.AddSingleton<IMeetingWorkflowEngine, MeetingWorkflowEngine>();
|
||||
builder.Services.AddSingleton<AsrDiagnosticService>();
|
||||
@@ -298,61 +299,53 @@ static async Task<IResult> RunCurrentSummaryAsync(
|
||||
|
||||
app.MapPost("/meetings/summary/retry", async (
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapPost("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
SummaryRetryRequest request,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
request.SummaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
app.MapGet("/meetings/summary/retry", async (
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
null,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
null,
|
||||
cancellationToken));
|
||||
app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
string launchProfile,
|
||||
string summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
IOptions<MeetingAssistantOptions> options,
|
||||
ILaunchProfileOptionsProvider launchProfiles,
|
||||
CancellationToken cancellationToken) =>
|
||||
await RetrySummaryAsync(
|
||||
launchProfile,
|
||||
summaryPath,
|
||||
artifactResolver,
|
||||
summaryPipeline,
|
||||
summaryRetryRunner,
|
||||
options.Value,
|
||||
launchProfiles,
|
||||
cancellationToken));
|
||||
@@ -360,8 +353,7 @@ app.MapGet("/profiles/{launchProfile}/meetings/summary/retry", async (
|
||||
static async Task<IResult> RetrySummaryAsync(
|
||||
string? launchProfile,
|
||||
string? summaryPath,
|
||||
IMeetingSummaryArtifactResolver artifactResolver,
|
||||
IMeetingSummaryPipeline summaryPipeline,
|
||||
IMeetingSummaryRetryRunner summaryRetryRunner,
|
||||
MeetingAssistantOptions defaultOptions,
|
||||
ILaunchProfileOptionsProvider? launchProfiles,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -372,13 +364,18 @@ static async Task<IResult> RetrySummaryAsync(
|
||||
}
|
||||
|
||||
var profileOptions = ResolveProfileOptions(launchProfile, defaultOptions, launchProfiles);
|
||||
var artifacts = await artifactResolver.ResolveBySummaryPathAsync(summaryPath, profileOptions, cancellationToken);
|
||||
if (artifacts is null)
|
||||
var trigger = await summaryRetryRunner.TriggerAsync(summaryPath, profileOptions, cancellationToken);
|
||||
if (trigger is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No meeting note links to summary '{summaryPath}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(await summaryPipeline.RunAsync(artifacts, profileOptions, cancellationToken));
|
||||
return Results.Accepted(value: new
|
||||
{
|
||||
summaryPath = trigger.SummaryPath,
|
||||
assistantContextPath = trigger.AssistantContextPath,
|
||||
state = "summarizing"
|
||||
});
|
||||
}
|
||||
|
||||
app.MapPost("/asr/transcribe-file", async (
|
||||
|
||||
@@ -97,9 +97,24 @@ public sealed class MeetingRecordingCoordinator
|
||||
string? launchProfileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return IsRecording
|
||||
? await StopAsync(cancellationToken)
|
||||
: await StartAsync(launchProfileName, cancellationToken);
|
||||
if (!IsRecording)
|
||||
{
|
||||
return await StartAsync(launchProfileName, cancellationToken);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launchProfileName))
|
||||
{
|
||||
return await StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var targetProfile = ResolveProfile(launchProfileName);
|
||||
var run = currentRun;
|
||||
if (run is not null && !run.IsLaunchProfile(targetProfile.Name))
|
||||
{
|
||||
return await SwitchProfileAsync(run, targetProfile, cancellationToken);
|
||||
}
|
||||
|
||||
return await StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RecordingStatus> StartAsync(CancellationToken cancellationToken)
|
||||
@@ -119,7 +134,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
var runOptions = ResolveOptions(launchProfileName);
|
||||
var launchProfile = ResolveProfile(launchProfileName);
|
||||
var runOptions = launchProfile.Options;
|
||||
currentSession = await transcriptStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||
var recordedAudio = await recordedAudioStore.CreateSessionAsync(runOptions, cancellationToken);
|
||||
var startedAt = DateTimeOffset.Now;
|
||||
@@ -173,6 +189,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
pipeline,
|
||||
pipelineOptions,
|
||||
runOptions,
|
||||
launchProfile.Name,
|
||||
runOptions.SpeakerIdentification.LiveSampleBufferDuration,
|
||||
runOptions.SpeakerIdentification.MaxSnippetsPerSpeaker);
|
||||
run.Task = Task.Run(() => RecordAsync(run), CancellationToken.None);
|
||||
@@ -318,12 +335,70 @@ public sealed class MeetingRecordingCoordinator
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<RecordingStatus> SwitchProfileAsync(
|
||||
RecordingRun run,
|
||||
LaunchProfile targetProfile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(currentRun, run) || run.IsCaptureStopping || run.IsLaunchProfile(targetProfile.Name))
|
||||
{
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
await run.PipelineGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Switching active meeting transcription profile from {FromProfile} to {ToProfile}",
|
||||
run.LaunchProfileName,
|
||||
targetProfile.Name);
|
||||
var pipeline = speechRecognitionPipelineFactory.Create(targetProfile.Name);
|
||||
var pipelineOptions = await BuildSpeechRecognitionPipelineOptionsAsync(
|
||||
targetProfile.Options,
|
||||
run.MeetingNotePath,
|
||||
cancellationToken);
|
||||
await pipeline.InitializeAsync(pipelineOptions, cancellationToken);
|
||||
|
||||
await run.Pipeline.CompleteAsync(cancellationToken);
|
||||
await run.WaitForLiveTranscriptTasksAsync(cancellationToken);
|
||||
|
||||
run.ResetSpeakerIdentification();
|
||||
run.MarkProfileSwitched();
|
||||
var marker = new TranscriptionSegment(
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
"System",
|
||||
$"Transcription profile changed to {targetProfile.Name}.");
|
||||
run.AddLiveSegment(marker);
|
||||
await transcriptStore.AppendAsync(run.Session, marker, cancellationToken);
|
||||
|
||||
run.ReplacePipeline(pipeline, pipelineOptions, targetProfile.Options, targetProfile.Name);
|
||||
run.AddLiveTranscriptTask(Task.Run(
|
||||
() => StoreLiveTranscriptAsync(run, pipeline, run.TranscriptionCancellation),
|
||||
CancellationToken.None));
|
||||
}
|
||||
finally
|
||||
{
|
||||
run.PipelineGate.Release();
|
||||
}
|
||||
|
||||
return CurrentStatus;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordAsync(RecordingRun run)
|
||||
{
|
||||
await run.Pipeline.InitializeAsync(run.PipelineOptions, run.TranscriptionCancellation);
|
||||
var liveTranscriptTask = Task.Run(
|
||||
() => StoreLiveTranscriptAsync(run, run.TranscriptionCancellation),
|
||||
CancellationToken.None);
|
||||
run.AddLiveTranscriptTask(Task.Run(
|
||||
() => StoreLiveTranscriptAsync(run, run.Pipeline, run.TranscriptionCancellation),
|
||||
CancellationToken.None));
|
||||
var captureTask = Task.Run(
|
||||
() => CaptureAudioAsync(run),
|
||||
CancellationToken.None);
|
||||
@@ -335,7 +410,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
await captureTask;
|
||||
await run.Pipeline.CompleteAsync(run.TranscriptionCancellation);
|
||||
await liveTranscriptTask;
|
||||
await run.WaitForLiveTranscriptTasksAsync(run.TranscriptionCancellation);
|
||||
run.CancelLiveIdentification();
|
||||
await liveIdentificationTask;
|
||||
await run.RecordedAudio.DisposeAsync();
|
||||
@@ -366,7 +441,9 @@ public sealed class MeetingRecordingCoordinator
|
||||
run.TranscriptionCancellation);
|
||||
}
|
||||
|
||||
await RunSummaryAsync(run, run.TranscriptionCancellation);
|
||||
var summaryState = await RunSummaryAsync(run, run.TranscriptionCancellation);
|
||||
await CompletePendingFinalSpeakerIdentificationAsync(run, run.TranscriptionCancellation);
|
||||
await TransitionMeetingAsync(run, summaryState, CancellationToken.None);
|
||||
}
|
||||
catch (OperationCanceledException) when (run.TranscriptionCancellation.IsCancellationRequested)
|
||||
{
|
||||
@@ -383,7 +460,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
run.CancelLiveIdentification();
|
||||
await run.RecordedAudio.DeleteAsync(CancellationToken.None);
|
||||
await run.Pipeline.DisposeAsync();
|
||||
await run.DisposePipelinesAsync();
|
||||
await CompleteRunAsync(run);
|
||||
}
|
||||
}
|
||||
@@ -407,9 +484,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
chunk.Channels);
|
||||
}
|
||||
|
||||
run.AppendAudio(chunk);
|
||||
await run.RecordedAudio.AppendAsync(chunk, run.CaptureCancellation);
|
||||
await run.Pipeline.WriteAsync(chunk, run.CaptureCancellation);
|
||||
await run.WriteAudioAsync(chunk, run.CaptureCancellation);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
@@ -432,9 +508,10 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
private async Task StoreLiveTranscriptAsync(
|
||||
RecordingRun run,
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var segment in run.Pipeline.ReadLiveTranscriptAsync(cancellationToken))
|
||||
await foreach (var segment in pipeline.ReadLiveTranscriptAsync(cancellationToken))
|
||||
{
|
||||
run.TryAddSpeakerSample(segment);
|
||||
var relabeledSegment = run.Relabel(segment);
|
||||
@@ -716,6 +793,18 @@ public sealed class MeetingRecordingCoordinator
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (run.HasSwitchedProfile)
|
||||
{
|
||||
var liveSegments = run.GetLiveSegmentsSnapshot();
|
||||
run.SetPendingFinalSpeakerIdentification(
|
||||
run.RecordedAudio.AudioPath,
|
||||
liveSegments,
|
||||
run.GetSpeakerSamplesSnapshot(),
|
||||
run.GetSpeakerMappingsSnapshot());
|
||||
await transcriptStore.ReplaceAsync(run.Session, liveSegments, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var finishedSegments = await run.Pipeline.ReadFinishedTranscriptAsync(
|
||||
run.RecordedAudio.AudioPath,
|
||||
await BuildSpeechRecognitionPipelineOptionsAsync(run.Options, run.MeetingNotePath, cancellationToken),
|
||||
@@ -728,19 +817,34 @@ public sealed class MeetingRecordingCoordinator
|
||||
finishedSegments = finishedSegments
|
||||
.Select(run.Relabel)
|
||||
.ToList();
|
||||
finishedSegments = await IdentifySpeakersAsync(
|
||||
var samples = run.GetSpeakerSamplesSnapshot();
|
||||
var knownSpeakerMappings = run.GetSpeakerMappingsSnapshot().ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var finishedSpeakerResult = await IdentifyFinishedSpeakersForTranscriptAsync(
|
||||
run.RecordedAudio.AudioPath,
|
||||
finishedSegments,
|
||||
run.GetSpeakerSamplesSnapshot(),
|
||||
run.GetSpeakerMappingsSnapshot(),
|
||||
samples,
|
||||
knownSpeakerMappings,
|
||||
run.Artifacts,
|
||||
run.MeetingNotePath,
|
||||
run.Options,
|
||||
cancellationToken);
|
||||
await transcriptStore.ReplaceAsync(run.Session, finishedSegments, cancellationToken);
|
||||
foreach (var (sourceSpeaker, targetSpeaker) in finishedSpeakerResult.SpeakerMappings)
|
||||
{
|
||||
knownSpeakerMappings[sourceSpeaker] = targetSpeaker;
|
||||
}
|
||||
|
||||
run.SetPendingFinalSpeakerIdentification(
|
||||
run.RecordedAudio.AudioPath,
|
||||
finishedSpeakerResult.Segments,
|
||||
samples,
|
||||
knownSpeakerMappings);
|
||||
await transcriptStore.ReplaceAsync(run.Session, finishedSpeakerResult.Segments, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TranscriptionSegment>> IdentifySpeakersAsync(
|
||||
private async Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersForTranscriptAsync(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> finishedSegments,
|
||||
IReadOnlyList<SpeakerAudioSample> samples,
|
||||
@@ -752,13 +856,13 @@ public sealed class MeetingRecordingCoordinator
|
||||
{
|
||||
if (speakerIdentificationService is null || string.IsNullOrWhiteSpace(meetingNotePath))
|
||||
{
|
||||
return finishedSegments;
|
||||
return new SpeakerIdentificationResult(finishedSegments, new Dictionary<string, string>());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(meetingNotePath, cancellationToken);
|
||||
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
||||
var result = await speakerIdentificationService.IdentifyFinishedSpeakersAsync(
|
||||
new SpeakerIdentificationRequest(
|
||||
audioPath,
|
||||
meetingNote,
|
||||
@@ -772,7 +876,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
meetingNotePath,
|
||||
runOptions,
|
||||
cancellationToken);
|
||||
return result.Segments;
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -780,8 +884,92 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Speaker identity learning failed; keeping ASR speaker labels");
|
||||
return finishedSegments;
|
||||
logger.LogWarning(exception, "Finished speaker identity matching failed; keeping ASR speaker labels");
|
||||
return new SpeakerIdentificationResult(finishedSegments, new Dictionary<string, string>());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompletePendingFinalSpeakerIdentificationAsync(
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = run.TakePendingFinalSpeakerIdentification();
|
||||
if (pending is null ||
|
||||
speakerIdentificationService is null ||
|
||||
string.IsNullOrWhiteSpace(run.MeetingNotePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var meetingNote = await meetingNoteStore.ReadAsync(run.MeetingNotePath, cancellationToken);
|
||||
var segments = pending.Segments.ToList();
|
||||
var knownSpeakerMappings = pending.KnownSpeakerMappings.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var speakerOverrides = await SpeakerOverrideArtifacts.ReadFromAssistantContextAsync(
|
||||
run.Artifacts.AssistantContextPath,
|
||||
cancellationToken);
|
||||
foreach (var speakerOverride in speakerOverrides)
|
||||
{
|
||||
await speakerIdentificationService.ApplySpeakerOverrideAsync(
|
||||
new SpeakerIdentificationRequest(
|
||||
pending.AudioPath,
|
||||
meetingNote,
|
||||
segments,
|
||||
pending.Samples,
|
||||
knownSpeakerMappings),
|
||||
speakerOverride.From,
|
||||
speakerOverride.To,
|
||||
cancellationToken);
|
||||
segments = segments
|
||||
.Select(segment => string.Equals(segment.Speaker, speakerOverride.From, StringComparison.OrdinalIgnoreCase)
|
||||
? segment with { Speaker = speakerOverride.To }
|
||||
: segment)
|
||||
.ToList();
|
||||
knownSpeakerMappings[speakerOverride.From] = speakerOverride.To;
|
||||
}
|
||||
|
||||
var identityDeletions = await SpeakerOverrideArtifacts.ReadIdentityDeletionsFromAssistantContextAsync(
|
||||
run.Artifacts.AssistantContextPath,
|
||||
cancellationToken);
|
||||
foreach (var deletion in identityDeletions)
|
||||
{
|
||||
await speakerIdentificationService.DeleteSpeakerIdentityAsync(
|
||||
deletion.Identity,
|
||||
cancellationToken);
|
||||
segments = segments
|
||||
.Select(segment => string.Equals(segment.Speaker, deletion.Identity, StringComparison.OrdinalIgnoreCase)
|
||||
? segment with { Speaker = deletion.Replacement }
|
||||
: segment)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var result = await speakerIdentificationService.ProcessFinishedTranscriptAsync(
|
||||
new SpeakerIdentificationRequest(
|
||||
pending.AudioPath,
|
||||
meetingNote,
|
||||
segments,
|
||||
pending.Samples,
|
||||
knownSpeakerMappings),
|
||||
cancellationToken);
|
||||
await AddIdentifiedSpeakersToMeetingAttendeesAsync(
|
||||
result.AttendeeMatches,
|
||||
run.Artifacts,
|
||||
run.MeetingNotePath,
|
||||
run.Options,
|
||||
cancellationToken);
|
||||
await transcriptStore.ReplaceAsync(run.Session, result.Segments, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Final speaker identity learning failed after summary");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -943,13 +1131,13 @@ public sealed class MeetingRecordingCoordinator
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RunSummaryAsync(
|
||||
private async Task<AssistantContextState> RunSummaryAsync(
|
||||
RecordingRun run,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (run.IsAborted)
|
||||
{
|
||||
return;
|
||||
return AssistantContextState.Finished;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -963,10 +1151,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
AssistantContextState.Summarizing,
|
||||
cancellationToken);
|
||||
var result = await summaryPipeline.RunAsync(run.Artifacts, run.Options, cancellationToken);
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
return result.Succeeded ? AssistantContextState.Finished : AssistantContextState.Error;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -975,10 +1160,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(exception, "Meeting summary pipeline failed unexpectedly");
|
||||
await TransitionMeetingAsync(
|
||||
run,
|
||||
AssistantContextState.Error,
|
||||
CancellationToken.None);
|
||||
return AssistantContextState.Error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,17 +1217,17 @@ public sealed class MeetingRecordingCoordinator
|
||||
: new SpeechRecognitionPipelineOptions(DictationWords: dictationWords);
|
||||
}
|
||||
|
||||
private MeetingAssistantOptions ResolveOptions(string? launchProfileName)
|
||||
private LaunchProfile ResolveProfile(string? launchProfileName)
|
||||
{
|
||||
if (launchProfiles is not null)
|
||||
{
|
||||
return launchProfiles.GetRequiredProfile(launchProfileName).Options;
|
||||
return launchProfiles.GetRequiredProfile(launchProfileName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(launchProfileName) ||
|
||||
launchProfileName.Equals(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return options;
|
||||
return new LaunchProfile(ConfigurationLaunchProfileOptionsProvider.DefaultProfileName, options);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
@@ -1102,11 +1284,15 @@ public sealed class MeetingRecordingCoordinator
|
||||
private int completed;
|
||||
private readonly object stateGate = new();
|
||||
private readonly object liveSegmentsGate = new();
|
||||
private readonly object liveTranscriptTasksGate = new();
|
||||
private readonly object liveIdentificationGate = new();
|
||||
private readonly List<TranscriptionSegment> liveSegments = [];
|
||||
private readonly List<Task> liveTranscriptTasks = [];
|
||||
private readonly List<ISpeechRecognitionPipeline> pipelines = [];
|
||||
private readonly ConcurrentDictionary<string, string> speakerMappings = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SpeakerAudioSampleCollector speakerSampleCollector;
|
||||
private LiveIdentificationCheckpoint? lastLiveIdentificationCheckpoint;
|
||||
private FinalSpeakerIdentificationWork? pendingFinalSpeakerIdentification;
|
||||
|
||||
public RecordingRun(
|
||||
CancellationTokenSource captureCancellation,
|
||||
@@ -1118,6 +1304,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||
MeetingAssistantOptions options,
|
||||
string launchProfileName,
|
||||
TimeSpan liveSampleBufferDuration,
|
||||
int maxSpeakerSamples)
|
||||
{
|
||||
@@ -1131,6 +1318,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
Pipeline = pipeline;
|
||||
PipelineOptions = pipelineOptions;
|
||||
Options = options;
|
||||
LaunchProfileName = launchProfileName;
|
||||
pipelines.Add(pipeline);
|
||||
speakerSampleCollector = new SpeakerAudioSampleCollector(liveSampleBufferDuration, maxSpeakerSamples);
|
||||
}
|
||||
|
||||
@@ -1148,11 +1337,15 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public MeetingSessionArtifacts Artifacts { get; }
|
||||
|
||||
public ISpeechRecognitionPipeline Pipeline { get; }
|
||||
public SemaphoreSlim PipelineGate { get; } = new(1, 1);
|
||||
|
||||
public SpeechRecognitionPipelineOptions PipelineOptions { get; }
|
||||
public ISpeechRecognitionPipeline Pipeline { get; private set; }
|
||||
|
||||
public MeetingAssistantOptions Options { get; }
|
||||
public SpeechRecognitionPipelineOptions PipelineOptions { get; private set; }
|
||||
|
||||
public MeetingAssistantOptions Options { get; private set; }
|
||||
|
||||
public string LaunchProfileName { get; private set; }
|
||||
|
||||
public CancellationToken CaptureCancellation => CaptureCancellationSource.Token;
|
||||
|
||||
@@ -1166,6 +1359,8 @@ public sealed class MeetingRecordingCoordinator
|
||||
|
||||
public bool IsAborted { get; private set; }
|
||||
|
||||
public bool HasSwitchedProfile { get; private set; }
|
||||
|
||||
public AssistantContextState ContextState { get; private set; } = AssistantContextState.CollectingMetadata;
|
||||
|
||||
public void StopCapture()
|
||||
@@ -1191,6 +1386,103 @@ public sealed class MeetingRecordingCoordinator
|
||||
LiveIdentificationCancellationSource.Cancel();
|
||||
}
|
||||
|
||||
public bool IsLaunchProfile(string launchProfileName)
|
||||
{
|
||||
return string.Equals(LaunchProfileName, launchProfileName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public async ValueTask WriteAudioAsync(AudioChunk chunk, CancellationToken cancellationToken)
|
||||
{
|
||||
await PipelineGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
AppendAudio(chunk);
|
||||
await Pipeline.WriteAsync(chunk, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PipelineGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void ReplacePipeline(
|
||||
ISpeechRecognitionPipeline pipeline,
|
||||
SpeechRecognitionPipelineOptions pipelineOptions,
|
||||
MeetingAssistantOptions options,
|
||||
string launchProfileName)
|
||||
{
|
||||
Pipeline = pipeline;
|
||||
PipelineOptions = pipelineOptions;
|
||||
Options = options;
|
||||
LaunchProfileName = launchProfileName;
|
||||
pipelines.Add(pipeline);
|
||||
}
|
||||
|
||||
public void AddLiveTranscriptTask(Task task)
|
||||
{
|
||||
lock (liveTranscriptTasksGate)
|
||||
{
|
||||
liveTranscriptTasks.Add(task);
|
||||
}
|
||||
}
|
||||
|
||||
public Task WaitForLiveTranscriptTasksAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Task[] tasks;
|
||||
lock (liveTranscriptTasksGate)
|
||||
{
|
||||
tasks = liveTranscriptTasks.ToArray();
|
||||
}
|
||||
|
||||
return Task.WhenAll(tasks).WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisposePipelinesAsync()
|
||||
{
|
||||
foreach (var pipeline in pipelines.Distinct())
|
||||
{
|
||||
await pipeline.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkProfileSwitched()
|
||||
{
|
||||
HasSwitchedProfile = true;
|
||||
}
|
||||
|
||||
public void ResetSpeakerIdentification()
|
||||
{
|
||||
speakerMappings.Clear();
|
||||
speakerSampleCollector.Reset();
|
||||
lock (liveIdentificationGate)
|
||||
{
|
||||
lastLiveIdentificationCheckpoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPendingFinalSpeakerIdentification(
|
||||
string audioPath,
|
||||
IReadOnlyList<TranscriptionSegment> segments,
|
||||
IReadOnlyList<SpeakerAudioSample> samples,
|
||||
IReadOnlyDictionary<string, string> knownSpeakerMappings)
|
||||
{
|
||||
pendingFinalSpeakerIdentification = new FinalSpeakerIdentificationWork(
|
||||
audioPath,
|
||||
segments.ToList(),
|
||||
samples.ToList(),
|
||||
knownSpeakerMappings.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => pair.Value,
|
||||
StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public FinalSpeakerIdentificationWork? TakePendingFinalSpeakerIdentification()
|
||||
{
|
||||
var pending = pendingFinalSpeakerIdentification;
|
||||
pendingFinalSpeakerIdentification = null;
|
||||
return pending;
|
||||
}
|
||||
|
||||
public bool TryTransitionTo(
|
||||
AssistantContextState state,
|
||||
out AssistantContextState fromState)
|
||||
@@ -1340,6 +1632,7 @@ public sealed class MeetingRecordingCoordinator
|
||||
CaptureCancellationSource.Dispose();
|
||||
TranscriptionCancellationSource.Dispose();
|
||||
LiveIdentificationCancellationSource.Dispose();
|
||||
PipelineGate.Dispose();
|
||||
}
|
||||
|
||||
private static int StateRank(AssistantContextState state)
|
||||
@@ -1367,6 +1660,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
Speakers.SequenceEqual(other.Speakers, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record FinalSpeakerIdentificationWork(
|
||||
string AudioPath,
|
||||
IReadOnlyList<TranscriptionSegment> Segments,
|
||||
IReadOnlyList<SpeakerAudioSample> Samples,
|
||||
IReadOnlyDictionary<string, string> KnownSpeakerMappings);
|
||||
}
|
||||
|
||||
private sealed class EmptyDictationWordStore : IDictationWordStore
|
||||
|
||||
@@ -31,6 +31,15 @@ internal sealed class RollingAudioBuffer
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
chunks.Clear();
|
||||
position = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] TryExtractWav(TimeSpan start, TimeSpan end)
|
||||
{
|
||||
if (end <= start)
|
||||
|
||||
@@ -21,6 +21,15 @@ internal sealed class SpeakerAudioSampleCollector
|
||||
audioBuffer.Append(chunk);
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
samplesBySpeaker.Clear();
|
||||
audioBuffer.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void TryAdd(TranscriptionSegment segment)
|
||||
{
|
||||
if (!IsDiarizedSpeaker(segment.Speaker))
|
||||
|
||||
@@ -59,6 +59,29 @@ public interface ISpeakerIdentificationService
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return IdentifyKnownSpeakersAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
Task ApplySpeakerOverrideAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task DeleteSpeakerIdentityAsync(
|
||||
string identity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -41,6 +41,94 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SpeakerIdentificationResult> IdentifyFinishedSpeakersAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.LiveReadOnly, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ApplySpeakerOverrideAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string sourceSpeaker,
|
||||
string targetSpeaker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled ||
|
||||
string.IsNullOrWhiteSpace(sourceSpeaker) ||
|
||||
string.IsNullOrWhiteSpace(targetSpeaker) ||
|
||||
string.Equals(sourceSpeaker, targetSpeaker, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceLabel = sourceSpeaker.Trim();
|
||||
var targetName = targetSpeaker.Trim();
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var meetingReference = CreateReference(request.MeetingNote, now);
|
||||
var snippet = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken);
|
||||
var target = await FindIdentityByAcceptedNameAsync(context, targetName, cancellationToken);
|
||||
var sourceCandidate = await FindCurrentRunCandidateAsync(
|
||||
context,
|
||||
meetingReference,
|
||||
targetName,
|
||||
cancellationToken);
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
target = sourceCandidate ?? new SpeakerIdentity
|
||||
{
|
||||
CreatedAt = now,
|
||||
CandidateNames = []
|
||||
};
|
||||
if (target.Id == 0)
|
||||
{
|
||||
context.SpeakerIdentities.Add(target);
|
||||
}
|
||||
}
|
||||
else if (sourceCandidate is not null && sourceCandidate.Id != target.Id)
|
||||
{
|
||||
MergeOverrideCandidate(target, sourceCandidate);
|
||||
context.SpeakerIdentities.Remove(sourceCandidate);
|
||||
}
|
||||
|
||||
target.CanonicalName = targetName;
|
||||
target.UpdatedAt = now;
|
||||
ResetCandidates(target, [targetName]);
|
||||
AddMeetingReference(target, meetingReference);
|
||||
AddSnippetIfNeeded(target, snippet);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||
target.References,
|
||||
sourceLabel,
|
||||
targetName,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteSpeakerIdentityAsync(
|
||||
string identity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Enabled || string.IsNullOrWhiteSpace(identity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
var target = await FindIdentityByAcceptedNameAsync(context, identity.Trim(), cancellationToken);
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.SpeakerIdentities.Remove(target);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SpeakerIdentificationResult> ProcessTranscriptAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
SpeakerIdentityProcessingMode mode,
|
||||
@@ -250,6 +338,115 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<byte[]> ResolveOverrideSnippetAsync(
|
||||
SpeakerIdentificationRequest request,
|
||||
string sourceSpeaker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = request.Samples?
|
||||
.Where(sample => string.Equals(sample.Speaker, sourceSpeaker, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(sample => sample.Score)
|
||||
.FirstOrDefault();
|
||||
if (sample is not null)
|
||||
{
|
||||
return sample.WavBytes;
|
||||
}
|
||||
|
||||
var segments = request.Segments
|
||||
.Where(segment => string.Equals(segment.Speaker, sourceSpeaker, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
return segments.Count == 0
|
||||
? []
|
||||
: await snippetExtractor.ExtractSnippetAsync(request.AudioPath, segments, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<SpeakerIdentity?> FindIdentityByAcceptedNameAsync(
|
||||
SpeakerIdentityDbContext context,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var identities = await context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.ToListAsync(cancellationToken);
|
||||
return identities
|
||||
.Where(identity => GetAcceptedNames(identity).Contains(name))
|
||||
.OrderBy(identity => string.Equals(identity.CanonicalName, name, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static async Task<SpeakerIdentity?> FindCurrentRunCandidateAsync(
|
||||
SpeakerIdentityDbContext context,
|
||||
SpeakerIdentityReference reference,
|
||||
string targetName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await context.SpeakerIdentities
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.Where(identity => string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||
.ToListAsync(cancellationToken);
|
||||
return candidates
|
||||
.Where(identity => identity.References.Any(existing => IsSameReference(existing, reference)))
|
||||
.Where(identity => identity.CandidateNames.Any(candidate =>
|
||||
string.Equals(candidate.Name, targetName, StringComparison.OrdinalIgnoreCase)) ||
|
||||
identity.Aliases.Any(alias =>
|
||||
string.Equals(alias.Name, targetName, StringComparison.OrdinalIgnoreCase)))
|
||||
.OrderBy(identity => identity.Id)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void MergeOverrideCandidate(
|
||||
SpeakerIdentity target,
|
||||
SpeakerIdentity source)
|
||||
{
|
||||
AddAlias(target, source.CanonicalName);
|
||||
foreach (var alias in source.Aliases)
|
||||
{
|
||||
AddAlias(target, alias.Name);
|
||||
}
|
||||
|
||||
foreach (var candidate in source.CandidateNames)
|
||||
{
|
||||
AddAlias(target, candidate.Name);
|
||||
}
|
||||
|
||||
foreach (var reference in source.References)
|
||||
{
|
||||
SpeakerIdentityReferences.AddIfMissing(target, reference);
|
||||
}
|
||||
|
||||
foreach (var snippet in source.Snippets)
|
||||
{
|
||||
AddSnippetIfNeeded(target, snippet.WavBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddAlias(SpeakerIdentity identity, string? alias)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(alias) ||
|
||||
string.Equals(identity.CanonicalName, alias, StringComparison.OrdinalIgnoreCase) ||
|
||||
identity.Aliases.Any(existing => string.Equals(existing.Name, alias, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
identity.Aliases.Add(new SpeakerAlias { Name = alias.Trim() });
|
||||
}
|
||||
|
||||
private static bool IsSameReference(
|
||||
SpeakerIdentityReference first,
|
||||
SpeakerIdentityReference second)
|
||||
{
|
||||
return string.Equals(first.MeetingNotePath, second.MeetingNotePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(first.TranscriptPath, second.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool MatchesAcceptedNames(
|
||||
SpeakerIdentity identity,
|
||||
IReadOnlySet<string> names)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
public sealed record SpeakerOverride(string From, string To, bool Merge = false);
|
||||
|
||||
public sealed record SpeakerIdentityDeletion(string Identity, string Replacement);
|
||||
|
||||
public static partial class SpeakerOverrideArtifacts
|
||||
{
|
||||
public static async Task ApplyToTranscriptAsync(
|
||||
string transcriptPath,
|
||||
SpeakerOverride speakerOverride,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(transcriptPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RewriteTranscriptSpeakerAsync(
|
||||
transcriptPath,
|
||||
speakerOverride.From,
|
||||
speakerOverride.To,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<bool> TranscriptContainsSpeakerAsync(
|
||||
string transcriptPath,
|
||||
string speaker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(transcriptPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||
return TranscriptContainsSpeaker(content, speaker);
|
||||
}
|
||||
|
||||
public static async Task AppendToAssistantContextAsync(
|
||||
string assistantContextPath,
|
||||
SpeakerOverride speakerOverride,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AppendMarkerAsync(assistantContextPath, FormatMarker(speakerOverride), cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<SpeakerIdentityDeletion> ApplyDeletionToTranscriptAsync(
|
||||
string transcriptPath,
|
||||
string identity,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var trimmedIdentity = identity.Trim();
|
||||
var replacement = "Removed-1";
|
||||
if (!File.Exists(transcriptPath))
|
||||
{
|
||||
return new SpeakerIdentityDeletion(trimmedIdentity, replacement);
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||
replacement = NextRemovedSpeakerLabel(content);
|
||||
await RewriteTranscriptSpeakerAsync(
|
||||
transcriptPath,
|
||||
trimmedIdentity,
|
||||
replacement,
|
||||
content,
|
||||
cancellationToken);
|
||||
|
||||
return new SpeakerIdentityDeletion(trimmedIdentity, replacement);
|
||||
}
|
||||
|
||||
public static async Task AppendIdentityDeletionToAssistantContextAsync(
|
||||
string assistantContextPath,
|
||||
SpeakerIdentityDeletion deletion,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AppendMarkerAsync(assistantContextPath, FormatMarker(deletion), cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<SpeakerOverride>> ReadFromAssistantContextAsync(
|
||||
string assistantContextPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var overrides = new List<SpeakerOverride>();
|
||||
foreach (Match match in SpeakerOverrideMarkerRegex().Matches(content))
|
||||
{
|
||||
SpeakerOverrideJson? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<SpeakerOverrideJson>(match.Groups["json"].Value);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parsed?.From) && !string.IsNullOrWhiteSpace(parsed.To))
|
||||
{
|
||||
overrides.Add(new SpeakerOverride(parsed.From.Trim(), parsed.To.Trim(), parsed.Merge));
|
||||
}
|
||||
}
|
||||
|
||||
return overrides
|
||||
.DistinctBy(speakerOverride => $"{speakerOverride.From}\n{speakerOverride.To}\n{speakerOverride.Merge}", StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<SpeakerIdentityDeletion>> ReadIdentityDeletionsFromAssistantContextAsync(
|
||||
string assistantContextPath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var content = await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
var deletions = new List<SpeakerIdentityDeletion>();
|
||||
foreach (Match match in SpeakerIdentityDeletionMarkerRegex().Matches(content))
|
||||
{
|
||||
SpeakerIdentityDeletionJson? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize<SpeakerIdentityDeletionJson>(match.Groups["json"].Value);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(parsed?.Identity) && !string.IsNullOrWhiteSpace(parsed.Replacement))
|
||||
{
|
||||
deletions.Add(new SpeakerIdentityDeletion(parsed.Identity.Trim(), parsed.Replacement.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
return deletions
|
||||
.DistinctBy(deletion => $"{deletion.Identity}\n{deletion.Replacement}", StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FormatMarker(SpeakerOverride speakerOverride)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SpeakerOverrideJson(
|
||||
speakerOverride.From,
|
||||
speakerOverride.To,
|
||||
speakerOverride.Merge));
|
||||
return $"<!-- meeting-assistant:speaker-override {json} -->";
|
||||
}
|
||||
|
||||
private static async Task<string> ReadExistingAssistantContextAsync(
|
||||
string assistantContextPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(assistantContextPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
"Speaker identity markers can only be written to an existing assistant context note.",
|
||||
assistantContextPath);
|
||||
}
|
||||
|
||||
return await File.ReadAllTextAsync(assistantContextPath, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RewriteTranscriptSpeakerAsync(
|
||||
string transcriptPath,
|
||||
string from,
|
||||
string to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||
await RewriteTranscriptSpeakerAsync(transcriptPath, from, to, content, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task RewriteTranscriptSpeakerAsync(
|
||||
string transcriptPath,
|
||||
string from,
|
||||
string to,
|
||||
string content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pattern = $@"^(\[[^\]]+\]\s*){Regex.Escape(from)}:";
|
||||
var updated = Regex.Replace(
|
||||
content,
|
||||
pattern,
|
||||
match => $"{match.Groups[1].Value}{to}:",
|
||||
RegexOptions.Multiline);
|
||||
if (!string.Equals(content, updated, StringComparison.Ordinal))
|
||||
{
|
||||
await File.WriteAllTextAsync(transcriptPath, updated, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AppendMarkerAsync(
|
||||
string assistantContextPath,
|
||||
string marker,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var content = await ReadExistingAssistantContextAsync(assistantContextPath, cancellationToken);
|
||||
if (content.Contains(marker, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (frontmatter, body) = MeetingArtifactFrontmatterRenderer.Split(content);
|
||||
var updatedBody = body.TrimEnd('\r', '\n') +
|
||||
(string.IsNullOrWhiteSpace(body) ? "" : Environment.NewLine + Environment.NewLine) +
|
||||
marker +
|
||||
Environment.NewLine;
|
||||
var updated = string.IsNullOrWhiteSpace(frontmatter)
|
||||
? updatedBody
|
||||
: "---" + Environment.NewLine + frontmatter + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine + updatedBody;
|
||||
await File.WriteAllTextAsync(assistantContextPath, updated, cancellationToken);
|
||||
}
|
||||
|
||||
private static bool TranscriptContainsSpeaker(string content, string speaker)
|
||||
{
|
||||
var pattern = $@"^\[[^\]]+\]\s*{Regex.Escape(speaker.Trim())}:";
|
||||
return Regex.IsMatch(content, pattern, RegexOptions.IgnoreCase | RegexOptions.Multiline);
|
||||
}
|
||||
|
||||
private static string FormatMarker(SpeakerIdentityDeletion deletion)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new SpeakerIdentityDeletionJson(deletion.Identity, deletion.Replacement));
|
||||
return $"<!-- meeting-assistant:speaker-identity-deletion {json} -->";
|
||||
}
|
||||
|
||||
private static string NextRemovedSpeakerLabel(string content)
|
||||
{
|
||||
var next = RemovedSpeakerLabelRegex()
|
||||
.Matches(content)
|
||||
.Select(match => int.TryParse(match.Groups["number"].Value, out var number) ? number : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max() + 1;
|
||||
return $"Removed-{next}";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<!--\s*meeting-assistant:speaker-override\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex SpeakerOverrideMarkerRegex();
|
||||
|
||||
[GeneratedRegex(@"<!--\s*meeting-assistant:speaker-identity-deletion\s+(?<json>\{.*?\})\s*-->", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
||||
private static partial Regex SpeakerIdentityDeletionMarkerRegex();
|
||||
|
||||
[GeneratedRegex(@"^\[[^\]]+\]\s*Removed-(?<number>\d+):", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
|
||||
private static partial Regex RemovedSpeakerLabelRegex();
|
||||
|
||||
private sealed record SpeakerOverrideJson(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("from")] string From,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("to")] string To,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("merge")] bool Merge = false);
|
||||
|
||||
private sealed record SpeakerIdentityDeletionJson(
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("identity")] string Identity,
|
||||
[property: System.Text.Json.Serialization.JsonPropertyName("replacement")] string Replacement);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"MeetingAssistant": {
|
||||
"Hotkey": {
|
||||
"Toggle": "Ctrl+Alt+M",
|
||||
"Abort": "Ctrl+Alt+D"
|
||||
"Abort": "Ctrl+Alt+Z"
|
||||
},
|
||||
"Vault": {
|
||||
"BaseFolder": "%USERPROFILE%\\OpenCloud\\Persönlich\\Vault\\Exxeta",
|
||||
@@ -125,7 +125,7 @@
|
||||
"Agent": {
|
||||
"Endpoint": "https://litellm.schweigert.cloud",
|
||||
"KeyEnv": "LITELLM_API_KEY",
|
||||
"Model": "copilot-gpt-5.5",
|
||||
"Model": "chatgpt/gpt-5.5",
|
||||
"EnableThinking": true,
|
||||
"ReasoningEffort": "Medium",
|
||||
"ReconnectionAttempts": 2,
|
||||
|
||||
Reference in New Issue
Block a user