Public Access
969 lines
40 KiB
C#
969 lines
40 KiB
C#
using MeetingAssistant.MeetingNotes;
|
|
using MeetingAssistant.Transcription;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace MeetingAssistant.Speakers;
|
|
|
|
public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
|
{
|
|
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
|
|
private readonly ISpeakerSnippetExtractor snippetExtractor;
|
|
private readonly ISpeakerIdentityMatcher matcher;
|
|
private readonly ISpeakerIdentityMatchValidator matchValidator;
|
|
private readonly SpeakerIdentificationOptions options;
|
|
private readonly ILogger<SpeakerIdentityService> logger;
|
|
|
|
public SpeakerIdentityService(
|
|
IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory,
|
|
ISpeakerSnippetExtractor snippetExtractor,
|
|
ISpeakerIdentityMatcher matcher,
|
|
IOptions<MeetingAssistantOptions> options,
|
|
ILogger<SpeakerIdentityService> logger,
|
|
ISpeakerIdentityMatchValidator matchValidator)
|
|
{
|
|
this.dbContextFactory = dbContextFactory;
|
|
this.snippetExtractor = snippetExtractor;
|
|
this.matcher = matcher;
|
|
this.matchValidator = matchValidator;
|
|
this.options = options.Value.SpeakerIdentification;
|
|
this.logger = logger;
|
|
}
|
|
|
|
public async Task<SpeakerIdentificationResult> ProcessFinishedTranscriptAsync(
|
|
SpeakerIdentificationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await ProcessTranscriptAsync(request, mode: SpeakerIdentityProcessingMode.Final, cancellationToken);
|
|
}
|
|
|
|
public async Task<SpeakerIdentificationResult> IdentifyKnownSpeakersAsync(
|
|
SpeakerIdentificationRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
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();
|
|
logger.LogInformation(
|
|
"Applying speaker override from {SourceSpeaker} to {TargetSpeaker} for meeting {MeetingNotePath}",
|
|
sourceLabel,
|
|
targetName,
|
|
request.MeetingNote.Path);
|
|
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var meetingReference = CreateReference(request.MeetingNote, now);
|
|
var snippetResolution = await ResolveOverrideSnippetAsync(request, sourceLabel, cancellationToken);
|
|
var snippet = snippetResolution.WavBytes;
|
|
if (snippet.Length > 0 && !await matchValidator.ValidateSampleAsync(snippet, cancellationToken))
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker override from {SourceSpeaker} to {TargetSpeaker} rejected source sample after secondary validation: sample source {SampleSource}, sample bytes {SampleBytes}",
|
|
sourceLabel,
|
|
targetName,
|
|
snippetResolution.Source,
|
|
snippet.Length);
|
|
snippet = [];
|
|
}
|
|
|
|
var target = await FindIdentityByAcceptedNameAsync(context, targetName, cancellationToken);
|
|
var sourceCandidate = await FindCurrentRunCandidateAsync(
|
|
context,
|
|
meetingReference,
|
|
targetName,
|
|
cancellationToken);
|
|
logger.LogInformation(
|
|
"Speaker override from {SourceSpeaker} to {TargetSpeaker} resolved sample source {SampleSource}, sample bytes {SampleBytes}, target identity {TargetIdentityId}, source candidate identity {SourceCandidateIdentityId}",
|
|
sourceLabel,
|
|
targetName,
|
|
snippetResolution.Source,
|
|
snippet.Length,
|
|
target?.Id,
|
|
sourceCandidate?.Id);
|
|
|
|
if (target is null)
|
|
{
|
|
if (sourceCandidate is null && snippet.Length == 0)
|
|
{
|
|
logger.LogWarning(
|
|
"Skipping speaker override from {SourceSpeaker} to {TargetSpeaker} because no source sample could be resolved",
|
|
sourceLabel,
|
|
targetName);
|
|
return;
|
|
}
|
|
|
|
target = sourceCandidate ?? new SpeakerIdentity
|
|
{
|
|
CreatedAt = now,
|
|
CandidateNames = []
|
|
};
|
|
if (target.Id == 0)
|
|
{
|
|
context.SpeakerIdentities.Add(target);
|
|
logger.LogInformation(
|
|
"Creating speaker identity from override for {TargetSpeaker} using source {SourceSpeaker}",
|
|
targetName,
|
|
sourceLabel);
|
|
}
|
|
}
|
|
else if (sourceCandidate is not null && sourceCandidate.Id != target.Id)
|
|
{
|
|
logger.LogInformation(
|
|
"Merging override source candidate identity {SourceCandidateIdentityId} into target identity {TargetIdentityId} for {TargetSpeaker}",
|
|
sourceCandidate.Id,
|
|
target.Id,
|
|
targetName);
|
|
MergeOverrideCandidate(target, sourceCandidate);
|
|
context.SpeakerIdentities.Remove(sourceCandidate);
|
|
}
|
|
|
|
target.CanonicalName = targetName;
|
|
target.UpdatedAt = now;
|
|
ResetCandidates(target, [targetName]);
|
|
AddMeetingReference(target, meetingReference);
|
|
var snippetAdded = AddSnippetIfNeeded(target, snippet);
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
logger.LogInformation(
|
|
"Applied speaker override from {SourceSpeaker} to {TargetSpeaker}: identity {IdentityId}, snippet added {SnippetAdded}, snippet count {SnippetCount}, reference count {ReferenceCount}",
|
|
sourceLabel,
|
|
targetName,
|
|
target.Id,
|
|
snippetAdded,
|
|
target.Snippets.Count,
|
|
target.References.Count);
|
|
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)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity deletion skipped because {IdentityName} was not found",
|
|
identity.Trim());
|
|
return;
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Deleting speaker identity {IdentityId} ({IdentityName}) with {SnippetCount} snippet(s), {CandidateCount} candidate(s), {AliasCount} alias(es), {ReferenceCount} reference(s)",
|
|
target.Id,
|
|
target.GetDisplayName() ?? identity.Trim(),
|
|
target.Snippets.Count,
|
|
target.CandidateNames.Count,
|
|
target.Aliases.Count,
|
|
target.References.Count);
|
|
context.SpeakerIdentities.Remove(target);
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private async Task<SpeakerIdentificationResult> ProcessTranscriptAsync(
|
|
SpeakerIdentificationRequest request,
|
|
SpeakerIdentityProcessingMode mode,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!options.Enabled || request.Segments.Count == 0)
|
|
{
|
|
return new SpeakerIdentificationResult(request.Segments, new Dictionary<string, string>());
|
|
}
|
|
|
|
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
|
|
|
var attendees = NormalizeAttendees(request.MeetingNote.Frontmatter.Attendees);
|
|
var meetingReference = CreateReference(request.MeetingNote, DateTimeOffset.UtcNow);
|
|
var speakerMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var attendeeMatches = new List<SpeakerIdentityAttendeeMatch>();
|
|
var knownSpeakerMappings = request.KnownSpeakerMappings ??
|
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
var knownDiarizedSpeakers = knownSpeakerMappings.Keys
|
|
.Where(speaker => !string.IsNullOrWhiteSpace(speaker))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var alreadyIdentifiedNames = knownSpeakerMappings.Values
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Select(name => name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var speaker in request.Segments
|
|
.Select(segment => segment.Speaker)
|
|
.Where(speaker => !string.IsNullOrWhiteSpace(speaker) && !IsDiarizedSpeakerLabel(speaker)))
|
|
{
|
|
alreadyIdentifiedNames.Add(speaker.Trim());
|
|
}
|
|
|
|
var matchedAcceptedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var name in alreadyIdentifiedNames)
|
|
{
|
|
matchedAcceptedNames.Add(name);
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity processing started in {Mode} mode for meeting {MeetingNotePath}: {SegmentCount} segment(s), {SampleCount} supplied sample(s), {KnownMappingCount} known mapping(s), attendees {Attendees}",
|
|
mode,
|
|
request.MeetingNote.Path,
|
|
request.Segments.Count,
|
|
request.Samples?.Count ?? 0,
|
|
knownSpeakerMappings.Count,
|
|
FormatNames(attendees));
|
|
|
|
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
|
|
var samplesBySpeaker = request.Samples?
|
|
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
|
|
.GroupBy(sample => sample.Speaker, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(
|
|
group => group.Key,
|
|
group => group.OrderByDescending(sample => sample.Score).Select(sample => sample.WavBytes).First(),
|
|
StringComparer.OrdinalIgnoreCase)
|
|
?? new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
|
|
if (samplesBySpeaker.Count > 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing has supplied samples for {SampleSpeakers}",
|
|
string.Join(", ", samplesBySpeaker.Select(pair => $"{pair.Key}:{pair.Value.Length} bytes")));
|
|
}
|
|
|
|
foreach (var group in request.Segments
|
|
.Where(segment => !string.IsNullOrWhiteSpace(segment.Speaker))
|
|
.GroupBy(segment => segment.Speaker)
|
|
.OrderBy(group => group.Min(segment => segment.Start)))
|
|
{
|
|
var speaker = group.Key;
|
|
if (knownDiarizedSpeakers.Contains(speaker) || alreadyIdentifiedNames.Contains(speaker))
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing skipped {Speaker} because it is already known or identified",
|
|
speaker);
|
|
continue;
|
|
}
|
|
|
|
if (speakerMappings.ContainsKey(speaker))
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing skipped {Speaker} because a mapping already exists to {MappedSpeaker}",
|
|
speaker,
|
|
speakerMappings[speaker]);
|
|
continue;
|
|
}
|
|
|
|
var snippetSource = "supplied";
|
|
var snippet = samplesBySpeaker.TryGetValue(speaker, out var suppliedSnippet)
|
|
? suppliedSnippet
|
|
: [];
|
|
if (snippet.Length == 0)
|
|
{
|
|
snippetSource = "extracted";
|
|
(snippet, _) = await ExtractBestContinuousSampleAsync(
|
|
request,
|
|
speaker,
|
|
"Speaker identity processing",
|
|
cancellationToken);
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity processing resolved {SampleSource} sample for {Speaker}: {SampleBytes} byte(s)",
|
|
snippetSource,
|
|
speaker,
|
|
snippet.Length);
|
|
if (snippet.Length == 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing cannot match or learn {Speaker} because no usable sample was available",
|
|
speaker);
|
|
unmatchedSpeakers.Add((speaker, snippet));
|
|
continue;
|
|
}
|
|
|
|
var match = await FindMatchAsync(
|
|
context,
|
|
attendees,
|
|
alreadyIdentifiedNames,
|
|
speaker,
|
|
snippet,
|
|
cancellationToken);
|
|
if (match is null)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing found no existing identity match for {Speaker}",
|
|
speaker);
|
|
unmatchedSpeakers.Add((speaker, snippet));
|
|
continue;
|
|
}
|
|
|
|
var identity = await LoadIdentityAsync(context, match.IdentityId, cancellationToken);
|
|
if (identity is null)
|
|
{
|
|
logger.LogWarning(
|
|
"Speaker identity processing matched {Speaker} to identity {IdentityId}, but the identity could not be loaded",
|
|
speaker,
|
|
match.IdentityId);
|
|
unmatchedSpeakers.Add((speaker, snippet));
|
|
continue;
|
|
}
|
|
|
|
if (mode == SpeakerIdentityProcessingMode.Final)
|
|
{
|
|
var previousCanonicalName = identity.CanonicalName;
|
|
AddMeetingReference(identity, meetingReference);
|
|
UpdateMatchedIdentity(identity, attendees, snippet);
|
|
logger.LogInformation(
|
|
"Speaker identity processing updated matched identity {IdentityId} for {Speaker}: previous canonical {PreviousCanonicalName}, canonical {CanonicalName}, candidates {CandidateNames}, snippets {SnippetCount}, references {ReferenceCount}",
|
|
identity.Id,
|
|
speaker,
|
|
previousCanonicalName,
|
|
identity.CanonicalName,
|
|
FormatNames(identity.CandidateNames.Select(candidate => candidate.Name)),
|
|
identity.Snippets.Count,
|
|
identity.References.Count);
|
|
if (string.IsNullOrWhiteSpace(previousCanonicalName) &&
|
|
!string.IsNullOrWhiteSpace(identity.CanonicalName))
|
|
{
|
|
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
|
identity.References,
|
|
speaker,
|
|
identity.CanonicalName,
|
|
cancellationToken);
|
|
}
|
|
|
|
foreach (var acceptedName in GetAcceptedNames(identity))
|
|
{
|
|
matchedAcceptedNames.Add(acceptedName);
|
|
}
|
|
}
|
|
|
|
var speakerName = identity.GetDisplayName();
|
|
if (!string.IsNullOrWhiteSpace(speakerName))
|
|
{
|
|
speakerMappings[speaker] = speakerName;
|
|
alreadyIdentifiedNames.Add(speakerName);
|
|
foreach (var acceptedName in GetAcceptedNames(identity))
|
|
{
|
|
alreadyIdentifiedNames.Add(acceptedName);
|
|
}
|
|
|
|
attendeeMatches.Add(new SpeakerIdentityAttendeeMatch(
|
|
speakerName,
|
|
GetAcceptedNames(identity).ToList()));
|
|
}
|
|
}
|
|
|
|
IReadOnlyList<SpeakerIdentity> learnedIdentities = [];
|
|
if (mode == SpeakerIdentityProcessingMode.Final)
|
|
{
|
|
learnedIdentities = await LearnUnmatchedSpeakersAsync(
|
|
context,
|
|
attendees,
|
|
matchedAcceptedNames,
|
|
unmatchedSpeakers,
|
|
meetingReference,
|
|
cancellationToken);
|
|
logger.LogInformation(
|
|
"Speaker identity processing queued {LearnedIdentityCount} new unmatched speaker identity candidate(s)",
|
|
learnedIdentities.Count);
|
|
}
|
|
|
|
await context.SaveChangesAsync(cancellationToken);
|
|
if (learnedIdentities.Count > 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity processing saved learned identity candidate ids {IdentityIds}",
|
|
FormatIds(learnedIdentities.Select(identity => identity.Id)));
|
|
}
|
|
logger.LogInformation(
|
|
"Speaker identity processing completed in {Mode} mode for meeting {MeetingNotePath}: {MappingCount} mapping(s), {AttendeeMatchCount} attendee match(es)",
|
|
mode,
|
|
request.MeetingNote.Path,
|
|
speakerMappings.Count,
|
|
attendeeMatches.Count);
|
|
|
|
var relabeledSegments = request.Segments
|
|
.Select(segment => speakerMappings.TryGetValue(segment.Speaker, out var speakerName)
|
|
? segment with { Speaker = speakerName }
|
|
: segment)
|
|
.ToList();
|
|
return new SpeakerIdentificationResult(relabeledSegments, speakerMappings, attendeeMatches);
|
|
}
|
|
|
|
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
|
|
SpeakerIdentityDbContext context,
|
|
IReadOnlyList<string> attendees,
|
|
IReadOnlySet<string> alreadyIdentifiedNames,
|
|
string speaker,
|
|
byte[] snippet,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var activeCutoff = DateTimeOffset.UtcNow - options.MatchIdentityActiveAge;
|
|
var maxCandidates = Math.Max(1, options.MaxMatchCandidates);
|
|
var identities = await context.SpeakerIdentities
|
|
.Include(identity => identity.Snippets)
|
|
.Include(identity => identity.Aliases)
|
|
.Include(identity => identity.References)
|
|
.OrderByDescending(identity => identity.References.Count)
|
|
.ThenBy(identity => identity.Id)
|
|
.ToListAsync(cancellationToken);
|
|
identities = identities
|
|
.Select(identity => new
|
|
{
|
|
Identity = identity,
|
|
IsAttendee = MatchesAttendees(identity, attendees),
|
|
IsActive = identity.UpdatedAt >= activeCutoff
|
|
})
|
|
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
|
|
.Where(candidate => !MatchesAcceptedNames(candidate.Identity, alreadyIdentifiedNames))
|
|
.OrderByDescending(candidate => candidate.IsAttendee)
|
|
.ThenByDescending(candidate => candidate.Identity.ReferenceCount)
|
|
.ThenBy(candidate => candidate.Identity.Id)
|
|
.Take(maxCandidates)
|
|
.Select(candidate => candidate.Identity)
|
|
.ToList();
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity match candidate selection for {Speaker}: {CandidateCount} candidate(s) after active/attendee/name filters, ids {IdentityIds}",
|
|
speaker,
|
|
identities.Count,
|
|
FormatIds(identities.Select(identity => identity.Id)));
|
|
|
|
var round = 0;
|
|
foreach (var batch in identities.Chunk(Math.Max(1, options.MatchBatchSize)))
|
|
{
|
|
round++;
|
|
var request = new SpeakerIdentityMatchRequest(
|
|
speaker,
|
|
snippet,
|
|
batch.Select(identity => new SpeakerIdentityMatchCandidate(
|
|
identity.Id,
|
|
identity.CanonicalName,
|
|
identity.ReferenceCount,
|
|
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
|
|
.ToList());
|
|
logger.LogInformation(
|
|
"Speaker identity match round {Round} for {Speaker}: candidate identity ids {IdentityIds}",
|
|
round,
|
|
speaker,
|
|
FormatIds(request.Candidates.Select(candidate => candidate.IdentityId)));
|
|
var match = await matcher.MatchAsync(request, cancellationToken);
|
|
if (match is not null)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity match round {Round} for {Speaker} matched identity {IdentityId}",
|
|
round,
|
|
speaker,
|
|
match.IdentityId);
|
|
return match;
|
|
}
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity match candidate selection for {Speaker} completed with no match after {RoundCount} round(s)",
|
|
speaker,
|
|
round);
|
|
return null;
|
|
}
|
|
|
|
private async Task<SpeakerSnippetResolution> 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 new SpeakerSnippetResolution(
|
|
sample.WavBytes,
|
|
"supplied-sample",
|
|
SegmentCount: 1,
|
|
sample.Score,
|
|
sample.Segment.End - sample.Segment.Start);
|
|
}
|
|
|
|
var segments = request.Segments
|
|
.Where(segment => string.Equals(segment.Speaker, sourceSpeaker, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
if (segments.Count == 0)
|
|
{
|
|
return new SpeakerSnippetResolution([], "missing-segments", SegmentCount: 0, Score: null, Duration: TimeSpan.Zero);
|
|
}
|
|
|
|
var (snippet, span) = await ExtractBestContinuousSampleAsync(
|
|
request,
|
|
sourceSpeaker,
|
|
"Speaker override",
|
|
cancellationToken);
|
|
return new SpeakerSnippetResolution(
|
|
snippet,
|
|
"extracted-from-recording",
|
|
span.Count,
|
|
Score: null,
|
|
SpeakerSampleSpanSelector.SpanDuration(span));
|
|
}
|
|
|
|
private async Task<(byte[] Snippet, IReadOnlyList<TranscriptionSegment> Span)> ExtractBestContinuousSampleAsync(
|
|
SpeakerIdentificationRequest request,
|
|
string speaker,
|
|
string operation,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var span = SpeakerSampleSpanSelector.SelectBestContinuousSpan(
|
|
request.Segments,
|
|
speaker,
|
|
options.MaximumSampleSegmentGap,
|
|
options.MinimumSampleSpeechDuration);
|
|
logger.LogInformation(
|
|
"{Operation} extracting fallback sample for {Speaker}: selected {SegmentCount} segment(s), span {SpanDuration}, minimum {MinimumDuration}",
|
|
operation,
|
|
speaker,
|
|
span.Count,
|
|
SpeakerSampleSpanSelector.SpanDuration(span),
|
|
options.MinimumSampleSpeechDuration);
|
|
var snippet = await snippetExtractor.ExtractSnippetAsync(request.AudioPath, span, cancellationToken);
|
|
return (snippet, span);
|
|
}
|
|
|
|
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)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker override candidate merge started: source identity {SourceIdentityId} into target identity {TargetIdentityId}",
|
|
source.Id,
|
|
target.Id);
|
|
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);
|
|
}
|
|
logger.LogInformation(
|
|
"Speaker override candidate merge completed: target identity {TargetIdentityId} now has {AliasCount} alias(es), {SnippetCount} snippet(s), {ReferenceCount} reference(s)",
|
|
target.Id,
|
|
target.Aliases.Count,
|
|
target.Snippets.Count,
|
|
target.References.Count);
|
|
}
|
|
|
|
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)
|
|
{
|
|
return GetAcceptedNames(identity).Any(names.Contains);
|
|
}
|
|
|
|
private static bool MatchesAttendees(
|
|
SpeakerIdentity identity,
|
|
IReadOnlyList<string> attendees)
|
|
{
|
|
var attendeeSet = attendees.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
return GetAcceptedNames(identity).Any(attendeeSet.Contains);
|
|
}
|
|
|
|
private static Task<SpeakerIdentity?> LoadIdentityAsync(
|
|
SpeakerIdentityDbContext context,
|
|
int identityId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return context.SpeakerIdentities
|
|
.Include(identity => identity.CandidateNames)
|
|
.Include(identity => identity.Aliases)
|
|
.Include(identity => identity.Snippets)
|
|
.Include(identity => identity.References)
|
|
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
|
|
}
|
|
|
|
private void UpdateMatchedIdentity(
|
|
SpeakerIdentity identity,
|
|
IReadOnlyList<string> attendees,
|
|
byte[] snippet)
|
|
{
|
|
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
|
|
|
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
|
|
{
|
|
var currentCandidates = identity.CandidateNames
|
|
.Select(candidate => candidate.Name)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var fallbackAliasCandidate = currentCandidates.Count == 1 ? currentCandidates.Single() : null;
|
|
var aliasToCandidate = identity.Aliases
|
|
.Where(alias => !string.IsNullOrWhiteSpace(alias.Name))
|
|
.SelectMany(alias => currentCandidates.Select(candidate => new { Alias = alias.Name, Candidate = candidate }))
|
|
.Where(pair => string.Equals(pair.Alias, pair.Candidate, StringComparison.OrdinalIgnoreCase) ||
|
|
pair.Alias.Contains(pair.Candidate, StringComparison.OrdinalIgnoreCase) ||
|
|
pair.Candidate.Contains(pair.Alias, StringComparison.OrdinalIgnoreCase))
|
|
.ToDictionary(pair => pair.Alias, pair => pair.Candidate, StringComparer.OrdinalIgnoreCase);
|
|
var intersection = attendees
|
|
.Select(attendee => currentCandidates.Contains(attendee)
|
|
? attendee
|
|
: aliasToCandidate.GetValueOrDefault(attendee) ??
|
|
(identity.Aliases.Any(alias => string.Equals(alias.Name, attendee, StringComparison.OrdinalIgnoreCase))
|
|
? fallbackAliasCandidate
|
|
: null))
|
|
.Where(candidate => !string.IsNullOrWhiteSpace(candidate))
|
|
.Select(candidate => candidate!)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
if (intersection.Count == 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity candidate elimination for identity {IdentityId} had empty intersection; resetting candidates to attendees {Attendees} and replacing oldest snippet",
|
|
identity.Id,
|
|
FormatNames(attendees));
|
|
ResetCandidates(identity, attendees);
|
|
ReplaceOldestSnippet(identity, snippet);
|
|
return;
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity candidate elimination for identity {IdentityId}: candidates {CurrentCandidates}, attendees {Attendees}, intersection {Intersection}",
|
|
identity.Id,
|
|
FormatNames(currentCandidates),
|
|
FormatNames(attendees),
|
|
FormatNames(intersection));
|
|
ResetCandidates(identity, intersection);
|
|
if (intersection.Count == 1)
|
|
{
|
|
identity.CanonicalName = intersection[0];
|
|
logger.LogInformation(
|
|
"Speaker identity candidate elimination promoted identity {IdentityId} to canonical name {CanonicalName}",
|
|
identity.Id,
|
|
identity.CanonicalName);
|
|
}
|
|
}
|
|
|
|
AddSnippetIfNeeded(identity, snippet);
|
|
}
|
|
|
|
private async Task<IReadOnlyList<SpeakerIdentity>> LearnUnmatchedSpeakersAsync(
|
|
SpeakerIdentityDbContext context,
|
|
IReadOnlyList<string> attendees,
|
|
IEnumerable<string> matchedCanonicalNames,
|
|
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
|
|
SpeakerIdentityReference meetingReference,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var remainingCandidates = attendees
|
|
.Except(matchedCanonicalNames, StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
if (remainingCandidates.Count == 0)
|
|
{
|
|
logger.LogInformation(
|
|
"Speaker identity candidate learning skipped because no attendee candidates remain. Matched names {MatchedNames}",
|
|
FormatNames(matchedCanonicalNames));
|
|
return [];
|
|
}
|
|
|
|
logger.LogInformation(
|
|
"Speaker identity candidate learning started for {UnmatchedSpeakerCount} unmatched speaker(s) with remaining candidate names {CandidateNames}",
|
|
unmatchedSpeakers.Count,
|
|
FormatNames(remainingCandidates));
|
|
var learned = new List<SpeakerIdentity>();
|
|
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
|
{
|
|
if (!await matchValidator.ValidateSampleAsync(snippet, cancellationToken))
|
|
{
|
|
logger.LogInformation(
|
|
"Skipping speaker identity candidate for {Speaker} because secondary validation rejected the sample: candidate names {CandidateNames}, sample bytes {SampleBytes}",
|
|
speaker,
|
|
FormatNames(remainingCandidates),
|
|
snippet.Length);
|
|
continue;
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
|
|
var identity = new SpeakerIdentity
|
|
{
|
|
CanonicalName = canonicalName,
|
|
CreatedAt = now,
|
|
UpdatedAt = now,
|
|
CandidateNames = remainingCandidates
|
|
.Select(candidate => new SpeakerCandidateName { Name = candidate })
|
|
.ToList(),
|
|
Snippets =
|
|
[
|
|
new SpeakerSnippet
|
|
{
|
|
WavBytes = snippet,
|
|
CreatedAt = now
|
|
}
|
|
],
|
|
References =
|
|
[
|
|
SpeakerIdentityReferences.Create(
|
|
meetingReference.MeetingNotePath,
|
|
meetingReference.TranscriptPath,
|
|
now)
|
|
]
|
|
};
|
|
context.SpeakerIdentities.Add(identity);
|
|
learned.Add(identity);
|
|
logger.LogInformation(
|
|
"Created speaker identity candidate for {Speaker}: canonical {CanonicalName}, candidate names {CandidateNames}, sample bytes {SampleBytes}, meeting reference {MeetingNotePath}",
|
|
speaker,
|
|
canonicalName,
|
|
FormatNames(remainingCandidates),
|
|
snippet.Length,
|
|
meetingReference.MeetingNotePath);
|
|
if (!string.IsNullOrWhiteSpace(canonicalName))
|
|
{
|
|
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
|
identity.References,
|
|
speaker,
|
|
canonicalName,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
|
|
foreach (var (speaker, _) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length == 0))
|
|
{
|
|
logger.LogInformation(
|
|
"Skipping speaker identity candidate for {Speaker} because no sample was available: candidate names {CandidateNames}",
|
|
speaker,
|
|
FormatNames(remainingCandidates));
|
|
}
|
|
|
|
return learned;
|
|
}
|
|
|
|
private bool AddSnippetIfNeeded(SpeakerIdentity identity, byte[] snippet)
|
|
{
|
|
if (snippet.Length == 0 || identity.Snippets.Count >= options.MaxSnippetsPerSpeaker)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
identity.Snippets.Add(new SpeakerSnippet
|
|
{
|
|
WavBytes = snippet,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private static void ReplaceOldestSnippet(SpeakerIdentity identity, byte[] snippet)
|
|
{
|
|
var oldest = identity.Snippets.OrderBy(storedSnippet => storedSnippet.CreatedAt).FirstOrDefault();
|
|
if (oldest is not null)
|
|
{
|
|
identity.Snippets.Remove(oldest);
|
|
}
|
|
|
|
if (snippet.Length > 0)
|
|
{
|
|
identity.Snippets.Add(new SpeakerSnippet
|
|
{
|
|
WavBytes = snippet,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
}
|
|
|
|
private static void ResetCandidates(SpeakerIdentity identity, IReadOnlyList<string> candidates)
|
|
{
|
|
identity.CandidateNames.Clear();
|
|
identity.CandidateNames.AddRange(candidates
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.Select(candidate => new SpeakerCandidateName { Name = candidate }));
|
|
}
|
|
|
|
private static IReadOnlySet<string> GetAcceptedNames(SpeakerIdentity identity)
|
|
{
|
|
return new[]
|
|
{
|
|
identity.CanonicalName
|
|
}
|
|
.Concat(identity.Aliases.Select(alias => alias.Name))
|
|
.Concat(identity.CandidateNames.Select(candidate => candidate.Name))
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Select(name => name!.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static IReadOnlyList<string> NormalizeAttendees(IEnumerable<string> attendees)
|
|
{
|
|
return attendees
|
|
.Select(NormalizeAttendee)
|
|
.Where(attendee => !string.IsNullOrWhiteSpace(attendee))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private static string NormalizeAttendee(string attendee)
|
|
{
|
|
return MeetingAttendeeNames.NormalizeDisplayName(attendee);
|
|
}
|
|
|
|
private static bool IsDiarizedSpeakerLabel(string speaker)
|
|
{
|
|
var normalized = speaker.Trim();
|
|
return normalized.Equals("Unknown", StringComparison.OrdinalIgnoreCase) ||
|
|
normalized.StartsWith("Guest", StringComparison.OrdinalIgnoreCase) ||
|
|
normalized.StartsWith("Speaker", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static SpeakerIdentityReference CreateReference(MeetingNote meetingNote, DateTimeOffset timestamp)
|
|
{
|
|
return SpeakerIdentityReferences.Create(
|
|
meetingNote.Path,
|
|
meetingNote.Frontmatter.Transcript,
|
|
timestamp);
|
|
}
|
|
|
|
private static void AddMeetingReference(
|
|
SpeakerIdentity identity,
|
|
SpeakerIdentityReference reference)
|
|
{
|
|
SpeakerIdentityReferences.AddIfMissing(identity, reference, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private static string FormatNames(IEnumerable<string?> names)
|
|
{
|
|
var formatted = names
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Select(name => name!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Order(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
return formatted.Count == 0 ? "<none>" : string.Join(", ", formatted);
|
|
}
|
|
|
|
private static string FormatIds(IEnumerable<int> ids)
|
|
{
|
|
var formatted = ids
|
|
.Distinct()
|
|
.Order()
|
|
.Select(id => id.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
|
.ToList();
|
|
return formatted.Count == 0 ? "<none>" : string.Join(", ", formatted);
|
|
}
|
|
|
|
private sealed record SpeakerSnippetResolution(
|
|
byte[] WavBytes,
|
|
string Source,
|
|
int SegmentCount,
|
|
double? Score,
|
|
TimeSpan Duration);
|
|
|
|
private enum SpeakerIdentityProcessingMode
|
|
{
|
|
LiveReadOnly,
|
|
Final
|
|
}
|
|
}
|