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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user