Make meeting lifecycle stateful

This commit is contained in:
2026-05-27 12:55:17 +02:00
parent d607b957bb
commit e85274829a
91 changed files with 4076 additions and 479 deletions
@@ -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);
+79 -2
View File
@@ -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);
}
}
}