Public Access
Make meeting lifecycle stateful
This commit is contained in:
@@ -52,7 +52,15 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = await diarizationClient.DiarizeAsync(tempPath, cancellationToken);
|
||||
var segments = await DiarizeWithTimeoutAsync(tempPath, cancellationToken);
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Speaker identity matching {DiarizedSpeaker} skipped because diarization returned no segments",
|
||||
request.DiarizedSpeaker);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Speaker identity matching {DiarizedSpeaker} received {SegmentCount} diarized segment(s)",
|
||||
request.DiarizedSpeaker,
|
||||
@@ -95,6 +103,31 @@ public sealed class AzureSpeechSpeakerIdentityMatcher : ISpeakerIdentityMatcher
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<TranscriptionSegment>> DiarizeWithTimeoutAsync(
|
||||
string wavPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchTimeout = options.MatchTimeout;
|
||||
if (matchTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
return await diarizationClient.DiarizeAsync(wavPath, cancellationToken);
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(matchTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
try
|
||||
{
|
||||
return await diarizationClient.DiarizeAsync(wavPath, linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Speaker identity diarization timed out after {Timeout}",
|
||||
matchTimeout);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private CompositeLayout WriteCompositeWav(string path, SpeakerIdentityMatchRequest request)
|
||||
{
|
||||
using var firstReader = OpenFirstReadableWave(request);
|
||||
|
||||
@@ -7,7 +7,8 @@ public sealed record SpeakerIdentificationRequest(
|
||||
string AudioPath,
|
||||
MeetingNote MeetingNote,
|
||||
IReadOnlyList<TranscriptionSegment> Segments,
|
||||
IReadOnlyList<SpeakerAudioSample>? Samples = null);
|
||||
IReadOnlyList<SpeakerAudioSample>? Samples = null,
|
||||
IReadOnlyDictionary<string, string>? KnownSpeakerMappings = null);
|
||||
|
||||
public sealed record SpeakerAudioSample(
|
||||
string Speaker,
|
||||
@@ -32,7 +33,7 @@ public sealed record SpeakerIdentityMatchRequest(
|
||||
public sealed record SpeakerIdentityMatchCandidate(
|
||||
int IdentityId,
|
||||
string? CanonicalName,
|
||||
int TranscriptionCount,
|
||||
int ReferenceCount,
|
||||
IReadOnlyList<byte[]> Snippets);
|
||||
|
||||
public sealed record SpeakerIdentityMatch(int IdentityId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
@@ -8,8 +9,6 @@ public sealed class SpeakerIdentity
|
||||
|
||||
public string? CanonicalName { get; set; }
|
||||
|
||||
public int TranscriptionCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
@@ -20,6 +19,11 @@ public sealed class SpeakerIdentity
|
||||
|
||||
public List<SpeakerSnippet> Snippets { get; set; } = [];
|
||||
|
||||
public List<SpeakerIdentityReference> References { get; set; } = [];
|
||||
|
||||
[NotMapped]
|
||||
public int ReferenceCount => References.Count;
|
||||
|
||||
public string? GetDisplayName()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CanonicalName))
|
||||
@@ -35,6 +39,65 @@ public sealed class SpeakerIdentity
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SpeakerIdentityReference
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int SpeakerIdentityId { get; set; }
|
||||
|
||||
public SpeakerIdentity? SpeakerIdentity { get; set; }
|
||||
|
||||
public string MeetingNotePath { get; set; } = "";
|
||||
|
||||
public string TranscriptPath { get; set; } = "";
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
public static class SpeakerIdentityReferences
|
||||
{
|
||||
public static SpeakerIdentityReference Create(
|
||||
string meetingNotePath,
|
||||
string transcriptPath,
|
||||
DateTimeOffset createdAt)
|
||||
{
|
||||
return new SpeakerIdentityReference
|
||||
{
|
||||
MeetingNotePath = meetingNotePath,
|
||||
TranscriptPath = transcriptPath,
|
||||
CreatedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static void AddIfMissing(
|
||||
SpeakerIdentity identity,
|
||||
SpeakerIdentityReference reference,
|
||||
DateTimeOffset? createdAt = null)
|
||||
{
|
||||
if (IsEmpty(reference) || identity.References.Any(existing => IsSame(existing, reference)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
identity.References.Add(Create(
|
||||
reference.MeetingNotePath,
|
||||
reference.TranscriptPath,
|
||||
createdAt ?? reference.CreatedAt));
|
||||
}
|
||||
|
||||
private static bool IsEmpty(SpeakerIdentityReference reference)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(reference.MeetingNotePath) &&
|
||||
string.IsNullOrWhiteSpace(reference.TranscriptPath);
|
||||
}
|
||||
|
||||
private static bool IsSame(SpeakerIdentityReference first, SpeakerIdentityReference second)
|
||||
{
|
||||
return string.Equals(first.MeetingNotePath, second.MeetingNotePath, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(first.TranscriptPath, second.TranscriptPath, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SpeakerCandidateName
|
||||
{
|
||||
public int Id { get; set; }
|
||||
@@ -85,10 +148,15 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
|
||||
public DbSet<SpeakerSnippet> SpeakerSnippets => Set<SpeakerSnippet>();
|
||||
|
||||
public DbSet<SpeakerIdentityReference> SpeakerIdentityReferences => Set<SpeakerIdentityReference>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<SpeakerIdentity>(entity =>
|
||||
{
|
||||
entity.Property<int>("TranscriptionCount")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
entity.HasMany(identity => identity.CandidateNames)
|
||||
.WithOne(candidate => candidate.SpeakerIdentity)
|
||||
.HasForeignKey(candidate => candidate.SpeakerIdentityId)
|
||||
@@ -103,6 +171,11 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
.WithOne(snippet => snippet.SpeakerIdentity)
|
||||
.HasForeignKey(snippet => snippet.SpeakerIdentityId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(identity => identity.References)
|
||||
.WithOne(reference => reference.SpeakerIdentity)
|
||||
.HasForeignKey(reference => reference.SpeakerIdentityId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SpeakerCandidateName>()
|
||||
@@ -112,5 +185,9 @@ public sealed class SpeakerIdentityDbContext : DbContext
|
||||
modelBuilder.Entity<SpeakerAlias>()
|
||||
.HasIndex(alias => new { alias.SpeakerIdentityId, alias.Name })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<SpeakerIdentityReference>()
|
||||
.HasIndex(reference => new { reference.SpeakerIdentityId, reference.MeetingNotePath, reference.TranscriptPath })
|
||||
.IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
public interface ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
private readonly IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory;
|
||||
|
||||
public SpeakerIdentityAttendeeCanonicalizer(IDbContextFactory<SpeakerIdentityDbContext> dbContextFactory)
|
||||
{
|
||||
this.dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attendeeEntries = attendees
|
||||
.Select(attendee => new
|
||||
{
|
||||
Raw = attendee.Trim(),
|
||||
DisplayName = NormalizeName(attendee)
|
||||
})
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Raw) && !string.IsNullOrWhiteSpace(entry.DisplayName))
|
||||
.ToList();
|
||||
if (attendeeEntries.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var context = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, cancellationToken);
|
||||
|
||||
var identities = await context.SpeakerIdentities
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.References)
|
||||
.ToListAsync(cancellationToken);
|
||||
var identitiesByAcceptedName = identities
|
||||
.OrderBy(identity => string.IsNullOrWhiteSpace(identity.CanonicalName))
|
||||
.ThenByDescending(identity => identity.ReferenceCount)
|
||||
.ThenByDescending(identity => identity.UpdatedAt)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.SelectMany(identity => GetExactAcceptedNames(identity).Select(name => new { Name = name, Identity = identity }))
|
||||
.GroupBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First().Identity, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = new List<string>();
|
||||
var seenNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var seenIdentityIds = new HashSet<int>();
|
||||
foreach (var attendee in attendeeEntries)
|
||||
{
|
||||
if (identitiesByAcceptedName.TryGetValue(attendee.DisplayName!, out var identity))
|
||||
{
|
||||
var displayName = identity.GetDisplayName();
|
||||
if (string.IsNullOrWhiteSpace(displayName) || !seenIdentityIds.Add(identity.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenNames.Add(displayName))
|
||||
{
|
||||
result.Add(displayName);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenNames.Add(attendee.Raw))
|
||||
{
|
||||
result.Add(attendee.Raw);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GetExactAcceptedNames(SpeakerIdentity identity)
|
||||
{
|
||||
return new[] { identity.CanonicalName }
|
||||
.Concat(identity.Aliases.Select(alias => alias.Name))
|
||||
.Select(NormalizeName)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Select(name => name!);
|
||||
}
|
||||
|
||||
private static string? NormalizeName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MeetingAttendeeNames.NormalizeDisplayName(name);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PassthroughSpeakerIdentityAttendeeCanonicalizer : ISpeakerIdentityAttendeeCanonicalizer
|
||||
{
|
||||
public static PassthroughSpeakerIdentityAttendeeCanonicalizer Instance { get; } = new();
|
||||
|
||||
private PassthroughSpeakerIdentityAttendeeCanonicalizer()
|
||||
{
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> CanonicalizeAsync(
|
||||
IReadOnlyList<string> attendees,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var distinctAttendees = attendees
|
||||
.Select(attendee => attendee.Trim())
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<string>>(distinctAttendees);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,8 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Snippets)
|
||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
||||
.Include(identity => identity.References)
|
||||
.OrderByDescending(identity => identity.References.Count)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -107,7 +108,14 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
continue;
|
||||
}
|
||||
|
||||
var targetName = target.GetDisplayName() ?? $"identity-{target.Id}";
|
||||
var sourceName = source.GetDisplayName() ?? $"identity-{source.Id}";
|
||||
MergeIdentities(target, source);
|
||||
await SpeakerIdentityTranscriptAudit.AppendMergedAsync(
|
||||
target.References,
|
||||
targetName,
|
||||
sourceName,
|
||||
cancellationToken);
|
||||
context.SpeakerIdentities.Remove(source);
|
||||
identities.Remove(source);
|
||||
mergedPairs++;
|
||||
@@ -138,7 +146,7 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
candidates.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||
identity.Id,
|
||||
identity.GetDisplayName(),
|
||||
identity.TranscriptionCount,
|
||||
identity.ReferenceCount,
|
||||
identity.Snippets
|
||||
.OrderBy(snippet => snippet.CreatedAt)
|
||||
.Select(snippet => snippet.WavBytes)
|
||||
@@ -166,8 +174,12 @@ public sealed class SpeakerIdentityMergeService : ISpeakerIdentityMergeService
|
||||
AddAlias(target, candidate.Name);
|
||||
}
|
||||
|
||||
target.TranscriptionCount += source.TranscriptionCount;
|
||||
target.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
foreach (var reference in source.References)
|
||||
{
|
||||
SpeakerIdentityReferences.AddIfMissing(target, reference);
|
||||
}
|
||||
|
||||
var retainedSnippets = target.Snippets
|
||||
.Concat(source.Snippets)
|
||||
.OrderBy(snippet => StableSnippetKey(snippet.WavBytes))
|
||||
|
||||
@@ -27,6 +27,25 @@ internal static class SpeakerIdentitySchema
|
||||
ON "SpeakerAliases" ("SpeakerIdentityId", "Name");
|
||||
""",
|
||||
cancellationToken);
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "SpeakerIdentityReferences" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_SpeakerIdentityReferences" PRIMARY KEY AUTOINCREMENT,
|
||||
"SpeakerIdentityId" INTEGER NOT NULL,
|
||||
"MeetingNotePath" TEXT NOT NULL,
|
||||
"TranscriptPath" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_SpeakerIdentityReferences_SpeakerIdentities_SpeakerIdentityId"
|
||||
FOREIGN KEY ("SpeakerIdentityId") REFERENCES "SpeakerIdentities" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""",
|
||||
cancellationToken);
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SpeakerIdentityReferences_SpeakerIdentityId_MeetingNotePath_TranscriptPath"
|
||||
ON "SpeakerIdentityReferences" ("SpeakerIdentityId", "MeetingNotePath", "TranscriptPath");
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task EnsureSpeakerIdentityTimestampColumnsAsync(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using MeetingAssistant.MeetingNotes;
|
||||
using MeetingAssistant.Transcription;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -54,9 +55,31 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
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);
|
||||
}
|
||||
|
||||
var unmatchedSpeakers = new List<(string Speaker, byte[] Snippet)>();
|
||||
var samplesBySpeaker = request.Samples?
|
||||
.Where(sample => !string.IsNullOrWhiteSpace(sample.Speaker) && sample.WavBytes.Length > 0)
|
||||
@@ -73,6 +96,11 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
.OrderBy(group => group.Min(segment => segment.Start)))
|
||||
{
|
||||
var speaker = group.Key;
|
||||
if (knownDiarizedSpeakers.Contains(speaker) || alreadyIdentifiedNames.Contains(speaker))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (speakerMappings.ContainsKey(speaker))
|
||||
{
|
||||
continue;
|
||||
@@ -90,7 +118,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
continue;
|
||||
}
|
||||
|
||||
var match = await FindMatchAsync(context, attendees, speaker, snippet, cancellationToken);
|
||||
var match = await FindMatchAsync(
|
||||
context,
|
||||
attendees,
|
||||
alreadyIdentifiedNames,
|
||||
speaker,
|
||||
snippet,
|
||||
cancellationToken);
|
||||
if (match is null)
|
||||
{
|
||||
unmatchedSpeakers.Add((speaker, snippet));
|
||||
@@ -106,7 +140,19 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||
{
|
||||
var previousCanonicalName = identity.CanonicalName;
|
||||
AddMeetingReference(identity, meetingReference);
|
||||
UpdateMatchedIdentity(identity, attendees, snippet);
|
||||
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);
|
||||
@@ -117,6 +163,12 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
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()));
|
||||
@@ -125,7 +177,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
if (mode == SpeakerIdentityProcessingMode.Final)
|
||||
{
|
||||
await LearnUnmatchedSpeakersAsync(context, attendees, matchedAcceptedNames, unmatchedSpeakers, cancellationToken);
|
||||
await LearnUnmatchedSpeakersAsync(
|
||||
context,
|
||||
attendees,
|
||||
matchedAcceptedNames,
|
||||
unmatchedSpeakers,
|
||||
meetingReference,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
@@ -141,6 +199,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
private async Task<SpeakerIdentityMatch?> FindMatchAsync(
|
||||
SpeakerIdentityDbContext context,
|
||||
IReadOnlyList<string> attendees,
|
||||
IReadOnlySet<string> alreadyIdentifiedNames,
|
||||
string speaker,
|
||||
byte[] snippet,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -150,7 +209,8 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
var identities = await context.SpeakerIdentities
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.Aliases)
|
||||
.OrderByDescending(identity => identity.TranscriptionCount)
|
||||
.Include(identity => identity.References)
|
||||
.OrderByDescending(identity => identity.References.Count)
|
||||
.ThenBy(identity => identity.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
identities = identities
|
||||
@@ -161,8 +221,9 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IsActive = identity.UpdatedAt >= activeCutoff
|
||||
})
|
||||
.Where(candidate => candidate.IsAttendee || candidate.IsActive)
|
||||
.Where(candidate => !MatchesAcceptedNames(candidate.Identity, alreadyIdentifiedNames))
|
||||
.OrderByDescending(candidate => candidate.IsAttendee)
|
||||
.ThenByDescending(candidate => candidate.Identity.TranscriptionCount)
|
||||
.ThenByDescending(candidate => candidate.Identity.ReferenceCount)
|
||||
.ThenBy(candidate => candidate.Identity.Id)
|
||||
.Take(maxCandidates)
|
||||
.Select(candidate => candidate.Identity)
|
||||
@@ -176,7 +237,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
batch.Select(identity => new SpeakerIdentityMatchCandidate(
|
||||
identity.Id,
|
||||
identity.CanonicalName,
|
||||
identity.TranscriptionCount,
|
||||
identity.ReferenceCount,
|
||||
identity.Snippets.Select(storedSnippet => storedSnippet.WavBytes).ToList()))
|
||||
.ToList());
|
||||
var match = await matcher.MatchAsync(request, cancellationToken);
|
||||
@@ -189,6 +250,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool MatchesAcceptedNames(
|
||||
SpeakerIdentity identity,
|
||||
IReadOnlySet<string> names)
|
||||
{
|
||||
return GetAcceptedNames(identity).Any(names.Contains);
|
||||
}
|
||||
|
||||
private static bool MatchesAttendees(
|
||||
SpeakerIdentity identity,
|
||||
IReadOnlyList<string> attendees)
|
||||
@@ -206,6 +274,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
.Include(identity => identity.CandidateNames)
|
||||
.Include(identity => identity.Aliases)
|
||||
.Include(identity => identity.Snippets)
|
||||
.Include(identity => identity.References)
|
||||
.SingleOrDefaultAsync(identity => identity.Id == identityId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -214,7 +283,6 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IReadOnlyList<string> attendees,
|
||||
byte[] snippet)
|
||||
{
|
||||
identity.TranscriptionCount++;
|
||||
identity.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identity.CanonicalName) && attendees.Count > 0)
|
||||
@@ -265,6 +333,7 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
IReadOnlyList<string> attendees,
|
||||
IEnumerable<string> matchedCanonicalNames,
|
||||
IReadOnlyList<(string Speaker, byte[] Snippet)> unmatchedSpeakers,
|
||||
SpeakerIdentityReference meetingReference,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var remainingCandidates = attendees
|
||||
@@ -276,11 +345,13 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (_, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
||||
foreach (var (speaker, snippet) in unmatchedSpeakers.Where(speaker => speaker.Snippet.Length > 0))
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
context.SpeakerIdentities.Add(new SpeakerIdentity
|
||||
var canonicalName = remainingCandidates.Count == 1 ? remainingCandidates[0] : null;
|
||||
var identity = new SpeakerIdentity
|
||||
{
|
||||
CanonicalName = canonicalName,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CandidateNames = remainingCandidates
|
||||
@@ -293,8 +364,24 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
WavBytes = snippet,
|
||||
CreatedAt = now
|
||||
}
|
||||
],
|
||||
References =
|
||||
[
|
||||
SpeakerIdentityReferences.Create(
|
||||
meetingReference.MeetingNotePath,
|
||||
meetingReference.TranscriptPath,
|
||||
now)
|
||||
]
|
||||
});
|
||||
};
|
||||
context.SpeakerIdentities.Add(identity);
|
||||
if (!string.IsNullOrWhiteSpace(canonicalName))
|
||||
{
|
||||
await SpeakerIdentityTranscriptAudit.AppendIdentifiedAsync(
|
||||
identity.References,
|
||||
speaker,
|
||||
canonicalName,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
@@ -366,9 +453,30 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
|
||||
|
||||
private static string NormalizeAttendee(string attendee)
|
||||
{
|
||||
var trimmed = attendee.Trim();
|
||||
var emailStart = trimmed.IndexOf('<', StringComparison.Ordinal);
|
||||
return emailStart > 0 ? trimmed[..emailStart].Trim() : trimmed;
|
||||
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 enum SpeakerIdentityProcessingMode
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace MeetingAssistant.Speakers;
|
||||
|
||||
internal static class SpeakerIdentityTranscriptAudit
|
||||
{
|
||||
public static Task AppendIdentifiedAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string speakerLabel,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendAsync(
|
||||
references,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd} {speakerLabel} was identified as {name}",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public static Task AppendMergedAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string name1,
|
||||
string name2,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return AppendAsync(
|
||||
references,
|
||||
$"{DateTimeOffset.Now:yyyy-MM-dd} {name1} and {name2} were merged",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task AppendAsync(
|
||||
IEnumerable<SpeakerIdentityReference> references,
|
||||
string line,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var transcriptPath in references
|
||||
.Select(reference => reference.TranscriptPath)
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!File.Exists(transcriptPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = await File.ReadAllTextAsync(transcriptPath, cancellationToken);
|
||||
if (existing.Contains(line, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separator = existing.EndsWith(Environment.NewLine, StringComparison.Ordinal)
|
||||
? ""
|
||||
: Environment.NewLine;
|
||||
await File.AppendAllTextAsync(
|
||||
transcriptPath,
|
||||
$"{separator}{line}{Environment.NewLine}",
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user