Skip to content

Commit 4614963

Browse files
committed
Fix unknown speaker filtering and discovery
1 parent fb9db3e commit 4614963

15 files changed

Lines changed: 2181 additions & 16 deletions

File tree

‎backends/advanced/src/advanced_omi_backend/controllers/data_audit_controller.py‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121

2222
from advanced_omi_backend.client_manager import synthetic_client_id
2323
from advanced_omi_backend.config import get_diarization_settings
24-
from advanced_omi_backend.constants import is_non_enrollable_speaker
24+
from advanced_omi_backend.constants import (
25+
is_non_enrollable_speaker,
26+
is_unknown_speaker_label,
27+
)
2528
from advanced_omi_backend.controllers.conversation_controller import (
2629
archive_conversation_audio,
2730
)
@@ -309,6 +312,20 @@ def _speakers_for_doc(doc: dict) -> List[str]:
309312
return sorted(speakers)
310313

311314

315+
def _speaker_facets_for_doc(doc: dict) -> tuple[List[str], bool]:
316+
"""Return global identities separately from conversation-local unknowns.
317+
318+
``Unknown Speaker N`` is numbered independently inside each conversation.
319+
It is a display description, not a corpus-wide identity, so it must never
320+
enter a global list of named speakers.
321+
"""
322+
labels = _speakers_for_doc(doc)
323+
return (
324+
[label for label in labels if not is_unknown_speaker_label(label)],
325+
any(is_unknown_speaker_label(label) for label in labels),
326+
)
327+
328+
312329
def _unknown_speech_count(doc: dict) -> int:
313330
"""Speech segments in the audited version not matched to an enrolled speaker.
314331
@@ -401,6 +418,7 @@ async def list_for_audit(
401418
created_before: Optional[datetime] = None,
402419
include_speakers: Optional[List[str]] = None,
403420
exclude_speakers: Optional[List[str]] = None,
421+
unknown_speakers: Optional[str] = None,
404422
dataset_id: Optional[str] = None,
405423
exported: Optional[str] = None,
406424
archived_only: bool = False,
@@ -508,12 +526,15 @@ async def list_for_audit(
508526
# compound predicate), so the filter UI offers exactly the labels that
509527
# exist in this view — and isn't narrowed by its own selection.
510528
available_speakers: set = set()
529+
has_unknown_speakers = False
511530

512531
for doc in raw_docs:
513532
duration = doc.get("audio_total_duration") or 0.0
514533
va = doc.get("vad_analysis")
515-
doc_speakers = _speakers_for_doc(doc)
534+
all_doc_speakers = _speakers_for_doc(doc)
535+
doc_speakers, doc_has_unknown = _speaker_facets_for_doc(doc)
516536
available_speakers.update(doc_speakers)
537+
has_unknown_speakers = has_unknown_speakers or doc_has_unknown
517538
unknown_count = _unknown_speech_count(doc)
518539

519540
speech_fraction = None
@@ -539,10 +560,16 @@ async def list_for_audit(
539560
continue
540561
if speech_fraction < min_speech_fraction:
541562
continue
542-
if include_set and not include_set.intersection(doc_speakers):
563+
include_matches = bool(include_set.intersection(doc_speakers))
564+
if (include_set or unknown_speakers == "include") and not (
565+
include_matches
566+
or (unknown_speakers == "include" and doc_has_unknown)
567+
):
543568
continue
544569
if exclude_set and exclude_set.intersection(doc_speakers):
545570
continue
571+
if unknown_speakers == "exclude" and doc_has_unknown:
572+
continue
546573
# Opt-in: drop conversations with nothing left to triage (every
547574
# speech segment already has an identified_as).
548575
if hide_reviewed and unknown_count == 0:
@@ -563,7 +590,7 @@ async def list_for_audit(
563590
"client_id": doc.get("client_id"),
564591
"created_at": created_at.isoformat() if created_at else None,
565592
"duration_seconds": duration,
566-
"speakers": doc_speakers,
593+
"speakers": all_doc_speakers,
567594
"unknown_speech_segments": unknown_count,
568595
"marginal_identified_segments": _marginal_identified_count(
569596
doc, similarity_threshold, marginal_margin
@@ -635,6 +662,7 @@ async def list_for_audit(
635662
"marginal_margin": marginal_margin,
636663
"unanalyzed_count": unanalyzed_count,
637664
"speakers": sorted(available_speakers),
665+
"has_unknown_speakers": has_unknown_speakers,
638666
"datasets": available_datasets,
639667
}
640668

‎backends/advanced/src/advanced_omi_backend/controllers/guided_enrollment_controller.py‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@
3030

3131
from advanced_omi_backend.config import get_diarization_settings
3232
from advanced_omi_backend.constants import is_non_enrollable_speaker
33+
from advanced_omi_backend.controllers import data_audit_controller
3334
from advanced_omi_backend.controllers.queue_controller import (
3435
JOB_RESULT_TTL,
3536
default_queue,
3637
)
38+
from advanced_omi_backend.models.annotation import Annotation, AnnotationType
3739
from advanced_omi_backend.models.conversation import Conversation
3840
from advanced_omi_backend.speaker_recognition_client import SpeakerRecognitionClient
3941
from advanced_omi_backend.users import User
@@ -44,6 +46,9 @@
4446
from advanced_omi_backend.workers.speaker_discovery_jobs import (
4547
discover_speaker_candidates_job,
4648
)
49+
from advanced_omi_backend.workers.unknown_speaker_jobs import (
50+
discover_unknown_speakers_job,
51+
)
4752

4853
logger = logging.getLogger(__name__)
4954

@@ -74,6 +79,196 @@ def _discovery_runs_collection():
7479
return Conversation.get_pymongo_collection().database["speaker_discovery_runs"]
7580

7681

82+
def _unknown_clusters_collection():
83+
return Conversation.get_pymongo_collection().database["unknown_speaker_clusters"]
84+
85+
86+
def _unknown_runs_collection():
87+
return Conversation.get_pymongo_collection().database["unknown_speaker_runs"]
88+
89+
90+
async def enqueue_unknown_discovery(user: User):
91+
"""Start or reattach to corpus-wide unknown-identity clustering."""
92+
user_id = str(user.user_id)
93+
existing = await _unknown_runs_collection().find_one({"requested_by": user_id})
94+
status = _job_status(existing.get("job_id") if existing else None)
95+
if status in {"queued", "started", "deferred", "scheduled"}:
96+
return {"job_id": existing["job_id"], "status": status, "reused": True}
97+
job = default_queue.enqueue(
98+
discover_unknown_speakers_job,
99+
requested_by=user_id,
100+
job_timeout=14400,
101+
result_ttl=JOB_RESULT_TTL,
102+
description="Discover unknown speakers across corpus",
103+
)
104+
await _unknown_runs_collection().update_one(
105+
{"requested_by": user_id},
106+
{"$set": {"job_id": job.id, "queued_at": datetime.now(timezone.utc)}},
107+
upsert=True,
108+
)
109+
return {"job_id": job.id, "status": "queued", "reused": False}
110+
111+
112+
async def list_unknown_clusters(user: User, limit: int = 50):
113+
cursor = (
114+
_unknown_clusters_collection()
115+
.find({"requested_by": str(user.user_id), "status": "pending"}, {"_id": 0})
116+
.sort("segment_count", -1)
117+
.limit(limit)
118+
)
119+
return {"clusters": await cursor.to_list(length=limit)}
120+
121+
122+
async def decide_unknown_cluster(
123+
user: User,
124+
cluster_id: str,
125+
run_fingerprint: str,
126+
action: str,
127+
speaker_name: Optional[str],
128+
accepted_identity_keys: list[str],
129+
enrollment_clips: list[dict],
130+
):
131+
"""Relabel accepted members and enroll only explicitly selected clips."""
132+
collection = _unknown_clusters_collection()
133+
cluster = await collection.find_one(
134+
{
135+
"requested_by": str(user.user_id),
136+
"cluster_id": cluster_id,
137+
"run_fingerprint": run_fingerprint,
138+
"status": "pending",
139+
}
140+
)
141+
if not cluster:
142+
return JSONResponse(status_code=409, content={"error": "Cluster is stale"})
143+
if action == "dismiss":
144+
await collection.update_one(
145+
{"_id": cluster["_id"]},
146+
{"$set": {"status": "dismissed", "decided_at": datetime.now(timezone.utc)}},
147+
)
148+
return {"status": "dismissed"}
149+
if not speaker_name or is_non_enrollable_speaker(speaker_name):
150+
return JSONResponse(
151+
status_code=422, content={"error": "A real speaker name is required"}
152+
)
153+
154+
accepted = {
155+
member["identity_key"]: member
156+
for member in cluster["members"]
157+
if member["identity_key"] in set(accepted_identity_keys)
158+
}
159+
if not accepted:
160+
return JSONResponse(
161+
status_code=422, content={"error": "Select at least one identity"}
162+
)
163+
if not enrollment_clips:
164+
return JSONResponse(
165+
status_code=422, content={"error": "Select at least one enrollment clip"}
166+
)
167+
168+
validated_segments = []
169+
for member in accepted.values():
170+
conversation = await Conversation.find_one(
171+
Conversation.conversation_id == member["conversation_id"]
172+
)
173+
if not conversation or conversation.user_id != str(user.user_id):
174+
return JSONResponse(
175+
status_code=409,
176+
content={"error": "Conversation is no longer available"},
177+
)
178+
segments = _active_segments(conversation.model_dump())
179+
for ref in member["segments"]:
180+
index = ref["segment_index"]
181+
if (
182+
index >= len(segments)
183+
or abs(float(segments[index]["start"]) - ref["start"]) > 0.01
184+
or not is_non_enrollable_speaker(
185+
segments[index].get("identified_as")
186+
or segments[index].get("speaker")
187+
)
188+
or (
189+
segments[index].get("identified_as")
190+
or segments[index].get("speaker")
191+
)
192+
!= member["local_label"]
193+
):
194+
return JSONResponse(
195+
status_code=409, content={"error": "Transcript changed since scan"}
196+
)
197+
validated_segments.append((member, ref, index, segments[index]))
198+
199+
annotations = 0
200+
for member, ref, index, segment in validated_segments:
201+
await Annotation(
202+
annotation_type=AnnotationType.DIARIZATION,
203+
user_id=str(user.user_id),
204+
conversation_id=member["conversation_id"],
205+
segment_index=index,
206+
original_speaker=segment.get("speaker") or "",
207+
corrected_speaker=speaker_name,
208+
segment_start_time=ref["start"],
209+
processed=False,
210+
).insert()
211+
annotations += 1
212+
213+
client = SpeakerRecognitionClient()
214+
existing_speaker = await client.get_speaker_by_name(
215+
speaker_name, user_id=str(user.user_id)
216+
)
217+
enrolled = appended = 0
218+
for clip in enrollment_clips:
219+
member = accepted.get(clip.get("identity_key"))
220+
if not member:
221+
continue
222+
ref = next(
223+
(
224+
segment
225+
for segment in member["segments"]
226+
if segment["segment_index"] == clip.get("segment_index")
227+
),
228+
None,
229+
)
230+
if not ref or ref["duration"] < MIN_CLIP_SECONDS:
231+
continue
232+
wav = await reconstruct_audio_segment(
233+
member["conversation_id"], ref["start"], ref["end"]
234+
)
235+
if existing_speaker:
236+
result = await client.append_to_speaker(
237+
existing_speaker["id"], wav, user_id=str(user.user_id)
238+
)
239+
if not result.get("error"):
240+
appended += 1
241+
else:
242+
result = await client.enroll_new_speaker(
243+
speaker_name, wav, user_id=str(user.user_id)
244+
)
245+
if not result.get("error"):
246+
enrolled += 1
247+
existing_speaker = await client.get_speaker_by_name(
248+
speaker_name, user_id=str(user.user_id)
249+
)
250+
251+
await collection.update_one(
252+
{"_id": cluster["_id"]},
253+
{
254+
"$set": {
255+
"status": "confirmed",
256+
"speaker_name": speaker_name,
257+
"accepted_identity_keys": list(accepted),
258+
"decided_at": datetime.now(timezone.utc),
259+
}
260+
},
261+
)
262+
apply_result = await data_audit_controller.apply_triage(user)
263+
return {
264+
"status": "confirmed",
265+
"annotations_created": annotations,
266+
"enrolled_new": enrolled,
267+
"appended": appended,
268+
"corrections_applied": getattr(apply_result, "body", apply_result),
269+
}
270+
271+
77272
def _job_status(job_id: Optional[str]) -> Optional[str]:
78273
if not job_id:
79274
return None

‎backends/advanced/src/advanced_omi_backend/routers/modules/data_audit_routes.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ class MergeRequest(BaseModel):
6868
)
6969

7070

71+
class UnknownClusterClip(BaseModel):
72+
identity_key: str
73+
segment_index: int
74+
75+
76+
class UnknownClusterDecision(BaseModel):
77+
run_fingerprint: str
78+
action: Literal["confirm", "dismiss"]
79+
speaker_name: Optional[str] = None
80+
accepted_identity_keys: List[str] = Field(default_factory=list)
81+
enrollment_clips: List[UnknownClusterClip] = Field(default_factory=list)
82+
83+
7184
class ScreenRequest(BaseModel):
7285
conversation_ids: List[str] = Field(..., min_length=1, max_length=200)
7386
policy: Optional[str] = Field(
@@ -177,6 +190,11 @@ async def list_conversations(
177190
exclude_speakers: Optional[str] = Query(
178191
None, description="Comma-separated speakers a conversation must contain none of"
179192
),
193+
unknown_speakers: Optional[Literal["include", "exclude"]] = Query(
194+
None,
195+
description="Include conversations containing any conversation-local unknown "
196+
"speaker, or exclude every such conversation",
197+
),
180198
dataset_id: Optional[str] = Query(
181199
None,
182200
max_length=200,
@@ -220,6 +238,7 @@ def _csv(v: Optional[str]) -> Optional[list]:
220238
created_before=created_before,
221239
include_speakers=_csv(include_speakers),
222240
exclude_speakers=_csv(exclude_speakers),
241+
unknown_speakers=unknown_speakers,
223242
dataset_id=dataset_id,
224243
exported=exported,
225244
archived_only=archived_only,
@@ -446,6 +465,38 @@ async def guided_enrollment_discover(
446465
)
447466

448467

468+
@router.post("/enrollment/unknown/discover")
469+
async def unknown_speaker_discover(
470+
current_user: User = Depends(current_active_user),
471+
):
472+
return await guided_enrollment_controller.enqueue_unknown_discovery(current_user)
473+
474+
475+
@router.get("/enrollment/unknown/clusters")
476+
async def unknown_speaker_clusters(
477+
limit: int = Query(50, ge=1, le=200),
478+
current_user: User = Depends(current_active_user),
479+
):
480+
return await guided_enrollment_controller.list_unknown_clusters(current_user, limit)
481+
482+
483+
@router.post("/enrollment/unknown/clusters/{cluster_id}/decide")
484+
async def unknown_speaker_cluster_decide(
485+
cluster_id: str,
486+
body: UnknownClusterDecision,
487+
current_user: User = Depends(current_active_user),
488+
):
489+
return await guided_enrollment_controller.decide_unknown_cluster(
490+
current_user,
491+
cluster_id,
492+
body.run_fingerprint,
493+
body.action,
494+
body.speaker_name,
495+
body.accepted_identity_keys,
496+
[clip.model_dump() for clip in body.enrollment_clips],
497+
)
498+
499+
449500
@router.post("/enrollment/guided/mine")
450501
async def guided_enrollment_mine(
451502
speaker_name: str = Form(..., description="Enrolled speaker to mine for"),

0 commit comments

Comments
 (0)