Prevent sampleless speaker identities
PR and Push Build/Test / build-and-test (push) Successful in 8m38s

This commit is contained in:
2026-06-02 10:39:00 +02:00
parent a3bad1bdd4
commit e3f4e87319
10 changed files with 101 additions and 50 deletions
@@ -327,6 +327,22 @@ public sealed class SpeakerIdentityServiceTests
Assert.Contains(saved.Snippets, snippet => snippet.WavBytes.SequenceEqual(new byte[] { 8, 8, 8 }));
}
[Fact]
public async Task SpeakerOverrideDoesNotCreateNamedIdentityWithoutSourceSample()
{
await using var fixture = await SpeakerIdentityFixture.CreateAsync();
var service = fixture.CreateService();
var segment = new TranscriptionSegment(TimeSpan.Zero, TimeSpan.FromSeconds(5), "Guest-01", "hello from Sabrina");
var request = fixture.CreateRequest(
["Sabrina"],
[segment],
[new SpeakerAudioSample("Guest-01", segment, [8, 8, 8], 95)]);
await service.ApplySpeakerOverrideAsync(request, "Guest-5", "Sabrina", CancellationToken.None);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task SpeakerIdentityDeletionRemovesAcceptedNameFromDatabase()
{
@@ -681,31 +681,34 @@ public sealed class WorkflowRulesEditorTests
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], candidates: ["Guest-01"], sample: [1]);
var tools = fixture.CreateTools();
var createResult = await tools.CreateIdentity(
"Sabrina",
["Sabi"],
["Guest-01"]);
using var created = JsonDocument.Parse(createResult);
var identityId = created.RootElement
.GetProperty("identity")
.GetProperty("id")
.GetInt32();
var searchResult = await tools.SearchIdentities("sabi");
var readResult = await tools.ReadIdentity(identityId);
var readResult = await tools.ReadIdentity(identity.Id);
var updateResult = await tools.UpdateIdentity(
identityId,
identity.Id,
"Sabrina M.",
["Sabrina"],
["Guest-02"]);
var deleteResult = await tools.DeleteIdentity(identityId);
var deleteResult = await tools.DeleteIdentity(identity.Id);
Assert.Contains("\"displayName\": \"Sabrina\"", searchResult);
Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult);
Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult);
Assert.Equal($"Deleted identity {identityId}.", deleteResult);
Assert.Equal($"Deleted identity {identity.Id}.", deleteResult);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task RulesEditorToolsRefusesSamplelessSpeakerIdentityCreation()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var tools = fixture.CreateTools();
var result = await tools.CreateIdentity("Sabrina", ["Sabi"], ["Guest-01"]);
Assert.StartsWith("Refused: speaker identities require at least one audio sample.", result);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
@@ -734,6 +737,13 @@ public sealed class WorkflowRulesEditorTests
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
var sampleId = identity.Snippets.Single().Id;
fixture.Context.SpeakerSnippets.Add(new SpeakerSnippet
{
SpeakerIdentityId = identity.Id,
WavBytes = [7, 8, 9],
CreatedAt = DateTimeOffset.UtcNow
});
await fixture.Context.SaveChangesAsync();
var queue = new CapturingSamplePlaybackQueue();
var tools = fixture.CreateTools(queue);
@@ -749,7 +759,25 @@ public sealed class WorkflowRulesEditorTests
Assert.Equal(sampleId, queued.SampleId);
Assert.Equal([4, 5, 6], queued.WavBytes);
Assert.Equal($"Deleted sample {sampleId}.", deleteResult);
Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync());
var remaining = await fixture.Context.SpeakerSnippets.ToListAsync();
Assert.Single(remaining);
Assert.NotEqual(sampleId, remaining.Single().Id);
}
[Fact]
public async Task RulesEditorToolsRefusesDeletingLastSpeakerIdentitySample()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
var sampleId = identity.Snippets.Single().Id;
var tools = fixture.CreateTools();
var result = await tools.DeleteIdentitySample(sampleId);
Assert.Equal(
$"Refused: sample {sampleId} is the last sample for identity {identity.Id}. Delete the identity instead.",
result);
Assert.Single(await fixture.Context.SpeakerSnippets.ToListAsync());
}
[Fact]
@@ -82,6 +82,15 @@ public sealed class SpeakerIdentityService : ISpeakerIdentificationService
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,
@@ -317,7 +317,7 @@ public sealed class WorkflowRulesEditorChatPipeline : IWorkflowRulesEditorChatPi
AIFunctionFactory.Create(
tools.CreateIdentity,
"create_identity",
"Create a speaker identity with optional canonicalName, aliases, and candidateNames. Returns the created identity JSON."),
"Refuse sampleless speaker identity creation and explain that identities must be created from an existing transcript speaker sample."),
AIFunctionFactory.Create(
tools.UpdateIdentity,
"update_identity",
@@ -61,7 +61,7 @@ public sealed class WorkflowRulesEditorInstructionBuilder : IWorkflowRulesEditor
"Project tools can create project folders and read, write, list, and search files in configured projects." + Environment.NewLine + Environment.NewLine +
"Meeting artifact tools can list recent summaries and read/search/write summaries, transcripts, meeting notes, and assistant context files for note post-processing and repair. Prefer frontmatter-specific write tools for metadata-only fixes." + Environment.NewLine + Environment.NewLine +
"Diagnostic tools mirror the local health, recording status, Outlook metadata, speaker identity merge, workflow reload, and ASR diagnostic endpoints without requiring HTTP." + Environment.NewLine + Environment.NewLine +
"Speaker identity tools can search/list/read/create/update/delete/merge identities, list/read/delete identity samples, and queue a sample for local playback." + Environment.NewLine + Environment.NewLine +
"Speaker identity tools can search/list/read/update/delete/merge identities, refuse sampleless identity creation, list/read/delete identity samples, and queue a sample for local playback. Do not delete the last sample from an identity; delete the identity instead." + Environment.NewLine + Environment.NewLine +
"Workflow rules reference documentation:" + Environment.NewLine +
"```markdown" + Environment.NewLine +
docs.Trim() + Environment.NewLine +
@@ -723,36 +723,14 @@ public sealed class WorkflowRulesEditorTools
}
}
public async Task<string> CreateIdentity(
public Task<string> CreateIdentity(
string? canonicalName = null,
string[]? aliases = null,
string[]? candidateNames = null)
{
var context = await CreatePreparedIdentityContextAsync(CancellationToken.None);
if (context is null)
{
return "Speaker identity database is not configured.";
}
await using (context)
{
var now = DateTimeOffset.UtcNow;
var identity = new SpeakerIdentity
{
CanonicalName = NormalizeNullable(canonicalName),
CreatedAt = now,
UpdatedAt = now,
Aliases = NormalizeNames(aliases)
.Select(name => new SpeakerAlias { Name = name })
.ToList(),
CandidateNames = NormalizeNames(candidateNames)
.Select(name => new SpeakerCandidateName { Name = name })
.ToList()
};
context.SpeakerIdentities.Add(identity);
await context.SaveChangesAsync();
return ToJson(ToIdentityDetail(identity));
}
return Task.FromResult(
"Refused: speaker identities require at least one audio sample. " +
"Use a speaker override from an existing transcript speaker sample, or update/merge an existing sampled identity.");
}
public async Task<string> UpdateIdentity(
@@ -902,6 +880,13 @@ public sealed class WorkflowRulesEditorTools
return $"Sample {sampleId} was not found.";
}
var sampleCount = await context.SpeakerSnippets.CountAsync(candidate =>
candidate.SpeakerIdentityId == sample.SpeakerIdentityId);
if (sampleCount <= 1)
{
return $"Refused: sample {sampleId} is the last sample for identity {sample.SpeakerIdentityId}. Delete the identity instead.";
}
context.SpeakerSnippets.Remove(sample);
await context.SaveChangesAsync();
return $"Deleted sample {sampleId}.";
@@ -24,9 +24,9 @@ The rules editor `write_rules` tool SHALL replace the whole configured workflow
The rules editor `write_rules` tool SHALL refuse invalid YAML, unsupported workflow rule shape, or workflow step Razor templates that fail against the workflow template model without changing the configured workflow rules file and SHALL return enough detail for the agent to fix and retry.
The settings/logs agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The settings/logs agent SHALL receive speaker identity tools to search/list, read, update, delete, and merge identities in the local speaker identity database, and a create tool that refuses sampleless identity creation with an actionable response.
The settings/logs agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
The settings/logs agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities. The delete sample tool SHALL refuse to delete the last remaining sample for an identity.
The settings/logs agent SHALL receive tools to read the local appsettings configuration file, write a complete valid JSON replacement for that file, read Meeting Assistant configuration documentation, read current or rotated application log files by tail or 1-based line range, search current or rotated application log files, search copied OpenSpec specification files, and read copied OpenSpec specification files by relative path and optional 1-based line range.
@@ -125,8 +125,10 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the settings/lo
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
- **THEN** it can search, read, create, update, delete, and merge speaker identities
- **THEN** it can search, read, update, delete, and merge speaker identities
- **AND** sampleless identity creation is refused with an actionable response
- **AND** it can list, read, delete, and queue playback of identity samples
- **AND** deleting the last sample for an identity is refused
#### Scenario: User sends a rules-editing chat turn
- **GIVEN** the settings/logs editor chat window is open
@@ -13,6 +13,7 @@
- [x] 2.7 Cover settings/logs recent-summary listing and meeting artifact read/search tools.
- [x] 2.8 Cover settings/logs meeting artifact write/frontmatter repair tools and in-process diagnostic tool availability.
- [x] 2.9 Cover summary frontmatter copying clean, distinct meeting projects.
- [x] 2.10 Cover speaker identity tools refusing sampleless identity creation and last-sample deletion.
## 3. Implementation
- [x] 3.1 Add app-owned temp-file logging with four older rotated files.
@@ -24,3 +25,4 @@
- [x] 3.7 Add settings/logs tools for recent summaries and summary/transcript/note/context read/search.
- [x] 3.8 Add settings/logs tools for summary/transcript/note/context writes, frontmatter repair, and diagnostics that previously required HTTP.
- [x] 3.9 Normalize copied meeting projects when writing summary frontmatter.
- [x] 3.10 Harden speaker identity writes so overrides, creation tools, and sample deletion cannot leave sampleless identities.
+5 -3
View File
@@ -208,9 +208,9 @@ The rules editor `write_rules` tool SHALL replace the whole configured workflow
The rules editor `write_rules` tool SHALL refuse invalid YAML without changing the configured workflow rules file.
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, create, update, delete, and merge identities in the local speaker identity database.
The rules and identities editor agent SHALL receive speaker identity tools to search/list, read, update, delete, and merge identities in the local speaker identity database, and a create tool that refuses sampleless identity creation with an actionable response.
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities.
The rules and identities editor agent SHALL receive speaker sample tools to list, read, delete, and queue playback of samples linked to identities. The delete sample tool SHALL refuse to delete the last remaining sample for an identity.
The first model request caused by each user-submitted chat turn SHALL send the `X-Initiator: user` header, while follow-up model requests within that same turn, such as tool-call continuations, SHALL send `X-Initiator: agent`.
@@ -259,8 +259,10 @@ Meeting Assistant SHALL provide a diagnostic endpoint that opens the workflow ru
#### Scenario: Rules editor can manage speaker identities
- **GIVEN** the speaker identity database contains speaker identities and samples
- **WHEN** the rules editor agent runs
- **THEN** it can search, read, create, update, delete, and merge speaker identities
- **THEN** it can search, read, update, delete, and merge speaker identities
- **AND** sampleless identity creation is refused with an actionable response
- **AND** it can list, read, delete, and queue playback of identity samples
- **AND** deleting the last sample for an identity is refused
#### Scenario: User sends a rules-editing chat turn
- **GIVEN** the rules editor chat window is open
+8 -1
View File
@@ -215,7 +215,7 @@ The configured maximum snippet count per identity SHALL prevent unbounded growth
Live speaker matching SHALL be read-only with respect to the speaker identity database. Candidate elimination, canonical promotion, transcription counters, stored snippet updates, and new unmatched identity creation SHALL happen only after transcription is finished and after automatic summary generation has completed, using the latest meeting note frontmatter.
When the summary agent records a speaker override from a diarized transcript label to a named speaker, final speaker identity processing SHALL attach the current run speaker sample to an existing identity with that name when one exists, or create a new canonical speaker identity with that name when none exists.
When the summary agent records a speaker override from a diarized transcript label to a named speaker, final speaker identity processing SHALL attach the current run speaker sample to an existing identity with that name when one exists, or create a new canonical speaker identity with that name when none exists. Meeting Assistant SHALL NOT create a new speaker identity for an override when no current run sample or current run candidate can be resolved for the source speaker label.
When a speaker override maps a current-run unnamed candidate to an existing named identity, Meeting Assistant SHALL merge the candidate's meeting reference and useful snippets into the named identity instead of leaving a duplicate candidate.
@@ -253,6 +253,13 @@ When the summary agent records that a speaker identity was wrongfully matched, f
- **THEN** Meeting Assistant creates a canonical speaker identity named `Sabrina`
- **AND** stores the meeting reference and current speaker sample on that identity
#### Scenario: Speaker override with missing source sample is skipped
- **GIVEN** the speaker identity database has no accepted name `Sabrina`
- **AND** the summary agent records that transcript speaker `Guest-5` is `Sabrina`
- **AND** final speaker identity processing has no sample or segment for `Guest-5`
- **WHEN** final speaker identity processing runs
- **THEN** Meeting Assistant does not create a speaker identity for `Sabrina`
#### Scenario: Speaker identity deletion removes a wrong match
- **GIVEN** the speaker identity database contains canonical speaker `Sabrina`
- **AND** the summary agent records that `Sabrina` was wrongfully matched