personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

speakers: suggest command for prioritized curation actions

Adds — a read-only
intelligence layer that composes existing speaker data into a prioritized
list of actionable curation suggestions. Four types: unknown_recurring
(from discovery cache), import_linkable (meeting participants without
voiceprints), name_variant (high-similarity entity pairs), and
low_confidence_review (days exceeding review threshold).

+809
+44
apps/speakers/call.py
··· 10 10 sol call speakers attribute-segment <day> <stream> <segment> [--json] 11 11 sol call speakers backfill [--dry-run] [--json] 12 12 sol call speakers discover [--json] 13 + sol call speakers suggest [--limit N] [--json] 13 14 sol call speakers identify <cluster-id> <name> [--entity-id ID] 14 15 sol call speakers merge-names <alias> <canonical> 15 16 """ ··· 343 344 f"sid={sample['sentence_id']}: {text_preview}" 344 345 ) 345 346 typer.echo() 347 + 348 + 349 + @app.command() 350 + def suggest( 351 + limit: int = typer.Option(5, "--limit", help="Maximum suggestions to return."), 352 + json_output: bool = typer.Option( 353 + False, "--json", help="Output full result as JSON." 354 + ), 355 + ) -> None: 356 + """Suggest speaker curation actions.""" 357 + import json as json_mod 358 + 359 + from apps.speakers.suggest import suggest_speakers 360 + 361 + suggestions = suggest_speakers(limit=limit) 362 + if json_output: 363 + typer.echo(json_mod.dumps(suggestions, indent=2, default=str)) 364 + return 365 + if not suggestions: 366 + typer.echo("No suggestions available.") 367 + raise typer.Exit() 368 + for s in suggestions: 369 + stype = s["type"] 370 + if stype == "unknown_recurring": 371 + typer.echo( 372 + f" [{stype}] Cluster {s['cluster_id']}: " 373 + f"{s['size']} samples across {s['segment_count']} segments" 374 + ) 375 + elif stype == "import_linkable": 376 + typer.echo( 377 + f" [{stype}] {s['name']}: " 378 + f"{s['meetings_count']} meetings, no voiceprint" 379 + ) 380 + elif stype == "name_variant": 381 + typer.echo( 382 + f" [{stype}] {s['names'][0]} / {s['names'][1]}: " 383 + f"similarity {s['similarity']:.4f}" 384 + ) 385 + elif stype == "low_confidence_review": 386 + typer.echo( 387 + f" [{stype}] {s['day']}: " 388 + f"{s['medium_count']} medium + {s['null_count']} null" 389 + ) 346 390 347 391 348 392 @app.command()
+397
apps/speakers/suggest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Speaker curation suggestions - computed on the fly from existing data.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + import logging 10 + from collections import defaultdict 11 + from datetime import time 12 + from pathlib import Path 13 + from typing import Any 14 + 15 + from think.utils import day_dirs, get_journal, segment_parse 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + def suggest_speakers(limit: int = 5) -> list[dict[str, Any]]: 21 + """Return prioritized speaker curation suggestions. 22 + 23 + Priority order: unknown_recurring > import_linkable > name_variant > 24 + low_confidence_review. Returns at most ``limit`` suggestions total. 25 + """ 26 + suggestions: list[dict[str, Any]] = [] 27 + suggestions.extend(_suggest_unknown_recurring()) 28 + suggestions.extend(_suggest_import_linkable()) 29 + suggestions.extend(_suggest_name_variants()) 30 + suggestions.extend(_suggest_low_confidence_review()) 31 + return suggestions[:limit] 32 + 33 + 34 + def _suggest_unknown_recurring() -> list[dict[str, Any]]: 35 + """Transform the discovery cache into actionable cluster suggestions.""" 36 + cache_path = Path(get_journal()) / "awareness" / "discovery_clusters.json" 37 + if not cache_path.exists(): 38 + return [] 39 + 40 + try: 41 + cache_data = json.loads(cache_path.read_text(encoding="utf-8")) 42 + except (json.JSONDecodeError, OSError): 43 + logger.warning("Failed to read discovery cache", exc_info=True) 44 + return [] 45 + 46 + clusters = cache_data.get("clusters") 47 + if not isinstance(clusters, dict): 48 + return [] 49 + 50 + suggestions: list[dict[str, Any]] = [] 51 + for cluster_id_str, records in clusters.items(): 52 + if not isinstance(records, list): 53 + continue 54 + try: 55 + cluster_id = int(cluster_id_str) 56 + except (TypeError, ValueError): 57 + continue 58 + 59 + segment_keys = { 60 + (record.get("day"), record.get("segment_key")) 61 + for record in records 62 + if isinstance(record, dict) 63 + } 64 + segments = sorted( 65 + { 66 + f"{record['day']}/{record['stream']}/{record['segment_key']}" 67 + for record in records 68 + if isinstance(record, dict) 69 + and record.get("day") 70 + and record.get("stream") 71 + and record.get("segment_key") 72 + } 73 + ) 74 + suggestions.append( 75 + { 76 + "type": "unknown_recurring", 77 + "cluster_id": cluster_id, 78 + "size": len(records), 79 + "segment_count": len( 80 + { 81 + (day, segment_key) 82 + for day, segment_key in segment_keys 83 + if day and segment_key 84 + } 85 + ), 86 + "segments": segments, 87 + "samples": records[:3], 88 + "import_hints": { 89 + "calendar_overlap": _calendar_overlap_for_segments(segments) 90 + }, 91 + } 92 + ) 93 + 94 + suggestions.sort(key=lambda item: item["size"], reverse=True) 95 + return suggestions 96 + 97 + 98 + def _suggest_import_linkable() -> list[dict[str, Any]]: 99 + """Suggest meeting participants who appear in events but lack voiceprints.""" 100 + participant_data: dict[str, dict[str, Any]] = {} 101 + for events_path in Path(get_journal()).glob("facets/*/events/*.jsonl"): 102 + day = events_path.stem 103 + try: 104 + with open(events_path, encoding="utf-8") as handle: 105 + for line in handle: 106 + try: 107 + event = json.loads(line) 108 + except json.JSONDecodeError: 109 + continue 110 + if ( 111 + event.get("type") != "meeting" 112 + or event.get("occurred") is not True 113 + ): 114 + continue 115 + participants = event.get("participants") 116 + if not isinstance(participants, list) or not participants: 117 + continue 118 + for name in participants: 119 + if not isinstance(name, str) or not name.strip(): 120 + continue 121 + entry = participant_data.setdefault( 122 + name, 123 + {"count": 0, "day_events": []}, 124 + ) 125 + entry["count"] += 1 126 + entry["day_events"].append((day, event)) 127 + except OSError: 128 + logger.warning("Failed reading event file %s", events_path, exc_info=True) 129 + continue 130 + 131 + if not participant_data: 132 + return [] 133 + 134 + from think.entities.journal import load_journal_entity, scan_journal_entities 135 + 136 + name_to_entity: dict[str, dict[str, Any]] = {} 137 + for entity_id in scan_journal_entities(): 138 + entity = load_journal_entity(entity_id) 139 + if not entity: 140 + continue 141 + entity_names = [entity.get("name", "")] 142 + entity_names.extend(entity.get("aka", [])) 143 + for name in entity_names: 144 + if isinstance(name, str) and name.strip(): 145 + name_to_entity[name.lower()] = entity 146 + 147 + suggestions: list[dict[str, Any]] = [] 148 + journal = Path(get_journal()) 149 + for name, entry in participant_data.items(): 150 + matched_entity = name_to_entity.get(name.lower()) 151 + has_voiceprint = False 152 + if matched_entity is not None: 153 + voiceprint_path = ( 154 + journal / "entities" / matched_entity["id"] / "voiceprints.npz" 155 + ) 156 + has_voiceprint = voiceprint_path.exists() 157 + if has_voiceprint: 158 + continue 159 + 160 + overlapping_segments: set[str] = set() 161 + segments_by_day: dict[str, list[tuple[str, str, time, time]]] = {} 162 + for day, event in entry["day_events"]: 163 + event_start = _parse_event_time(event.get("start")) 164 + event_end = _parse_event_time(event.get("end")) 165 + if event_start is None or event_end is None: 166 + continue 167 + day_segments = segments_by_day.setdefault(day, _iter_day_segments(day)) 168 + for stream, segment_key, seg_start, seg_end in day_segments: 169 + if _time_overlaps(seg_start, seg_end, event_start, event_end): 170 + overlapping_segments.add(f"{day}/{stream}/{segment_key}") 171 + 172 + suggestions.append( 173 + { 174 + "type": "import_linkable", 175 + "name": name, 176 + "source": "meetings", 177 + "meetings_count": entry["count"], 178 + "has_voiceprint": False, 179 + "overlapping_segments": sorted(overlapping_segments), 180 + } 181 + ) 182 + 183 + suggestions.sort(key=lambda item: item["meetings_count"], reverse=True) 184 + return suggestions 185 + 186 + 187 + def _suggest_name_variants() -> list[dict[str, Any]]: 188 + """Return high-similarity speaker name pairs with resolved entity IDs.""" 189 + from apps.speakers.bootstrap import resolve_name_variants 190 + from think.entities.journal import load_journal_entity, scan_journal_entities 191 + 192 + stats = resolve_name_variants(dry_run=True) 193 + matches = stats.get("matches_found", []) 194 + if not isinstance(matches, list): 195 + return [] 196 + 197 + name_to_entity_id: dict[str, str] = {} 198 + for entity_id in scan_journal_entities(): 199 + entity = load_journal_entity(entity_id) 200 + if not entity: 201 + continue 202 + name = entity.get("name") 203 + if isinstance(name, str) and name.strip(): 204 + name_to_entity_id[name.lower()] = entity_id 205 + 206 + suggestions = [] 207 + for match in matches: 208 + if not isinstance(match, dict): 209 + continue 210 + name_a = match.get("name_a") 211 + name_b = match.get("name_b") 212 + if not isinstance(name_a, str) or not isinstance(name_b, str): 213 + continue 214 + suggestions.append( 215 + { 216 + "type": "name_variant", 217 + "names": [name_a, name_b], 218 + "entity_ids": [ 219 + name_to_entity_id.get(name_a.lower()), 220 + name_to_entity_id.get(name_b.lower()), 221 + ], 222 + "similarity": match.get("similarity"), 223 + } 224 + ) 225 + 226 + return suggestions 227 + 228 + 229 + def _suggest_low_confidence_review() -> list[dict[str, Any]]: 230 + """Suggest days with a large number of medium or null speaker labels.""" 231 + journal = Path(get_journal()) 232 + by_day: dict[str, dict[str, Any]] = defaultdict( 233 + lambda: {"medium_count": 0, "null_count": 0, "segments": set()} 234 + ) 235 + 236 + for day in day_dirs().keys(): 237 + day_dir = journal / day 238 + if not day_dir.is_dir(): 239 + continue 240 + for stream_dir in sorted(day_dir.iterdir()): 241 + if not stream_dir.is_dir(): 242 + continue 243 + for seg_dir in sorted(stream_dir.iterdir()): 244 + if not seg_dir.is_dir(): 245 + continue 246 + labels_path = seg_dir / "agents" / "speaker_labels.json" 247 + if not labels_path.exists(): 248 + continue 249 + try: 250 + data = json.loads(labels_path.read_text(encoding="utf-8")) 251 + except (json.JSONDecodeError, OSError): 252 + continue 253 + needs_review = False 254 + for label in data.get("labels", []): 255 + if label.get("confidence") == "medium": 256 + by_day[day]["medium_count"] += 1 257 + needs_review = True 258 + if label.get("speaker") is None: 259 + by_day[day]["null_count"] += 1 260 + needs_review = True 261 + if needs_review: 262 + by_day[day]["segments"].add( 263 + f"{day}/{stream_dir.name}/{seg_dir.name}" 264 + ) 265 + 266 + suggestions = [] 267 + for day, info in by_day.items(): 268 + total = info["medium_count"] + info["null_count"] 269 + if total <= 10: 270 + continue 271 + suggestions.append( 272 + { 273 + "type": "low_confidence_review", 274 + "day": day, 275 + "medium_count": info["medium_count"], 276 + "null_count": info["null_count"], 277 + "segments_needing_review": sorted(info["segments"]), 278 + } 279 + ) 280 + 281 + suggestions.sort( 282 + key=lambda item: item["medium_count"] + item["null_count"], 283 + reverse=True, 284 + ) 285 + return suggestions 286 + 287 + 288 + def _calendar_overlap_for_segments(segments: list[str]) -> list[dict[str, Any]]: 289 + """Find meeting events that overlap with the given segment strings. 290 + 291 + Args: 292 + segments: list of "day/stream/segment_key" strings 293 + """ 294 + segments_by_day: dict[str, list[tuple[str, str, time, time]]] = defaultdict(list) 295 + for segment in segments: 296 + parts = segment.split("/") 297 + if len(parts) != 3: 298 + continue 299 + day, stream, segment_key = parts 300 + seg_start, seg_end = segment_parse(segment_key) 301 + if seg_start is None or seg_end is None: 302 + continue 303 + segments_by_day[day].append((stream, segment_key, seg_start, seg_end)) 304 + 305 + overlaps: list[dict[str, Any]] = [] 306 + for day, segment_entries in segments_by_day.items(): 307 + for event in _load_day_events(day): 308 + if event.get("type") != "meeting": 309 + continue 310 + participants = event.get("participants") 311 + if not isinstance(participants, list) or not participants: 312 + continue 313 + event_start = _parse_event_time(event.get("start")) 314 + event_end = _parse_event_time(event.get("end")) 315 + if event_start is None or event_end is None: 316 + continue 317 + 318 + overlapping_segments = [] 319 + for stream, segment_key, seg_start, seg_end in segment_entries: 320 + segment_label = f"{day}/{stream}/{segment_key}" 321 + if _time_overlaps(seg_start, seg_end, event_start, event_end): 322 + overlapping_segments.append(segment_label) 323 + 324 + if overlapping_segments: 325 + overlaps.append( 326 + { 327 + "day": day, 328 + "title": event.get("title", ""), 329 + "facet": event.get("facet", ""), 330 + "participants": participants, 331 + "start": event.get("start"), 332 + "end": event.get("end"), 333 + "segments": sorted(set(overlapping_segments)), 334 + } 335 + ) 336 + 337 + return overlaps 338 + 339 + 340 + def _time_overlaps( 341 + seg_start: time, seg_end: time, event_start: time, event_end: time 342 + ) -> bool: 343 + """Return True if two time ranges overlap.""" 344 + return seg_start < event_end and event_start < seg_end 345 + 346 + 347 + def _load_day_events(day: str) -> list[dict[str, Any]]: 348 + """Load all events for a day from facets/*/events/{day}.jsonl.""" 349 + events: list[dict[str, Any]] = [] 350 + for events_path in Path(get_journal()).glob(f"facets/*/events/{day}.jsonl"): 351 + try: 352 + with open(events_path, encoding="utf-8") as handle: 353 + for line in handle: 354 + try: 355 + event = json.loads(line) 356 + except json.JSONDecodeError: 357 + continue 358 + if not isinstance(event, dict): 359 + continue 360 + event.setdefault("facet", events_path.parent.parent.name) 361 + events.append(event) 362 + except OSError: 363 + logger.warning( 364 + "Failed reading day events from %s", events_path, exc_info=True 365 + ) 366 + continue 367 + return events 368 + 369 + 370 + def _iter_day_segments(day: str) -> list[tuple[str, str, time, time]]: 371 + """Return (stream, segment_key, start_time, end_time) for all segments on a day.""" 372 + day_dir = Path(get_journal()) / day 373 + if not day_dir.is_dir(): 374 + return [] 375 + 376 + segments = [] 377 + for stream_dir in sorted(day_dir.iterdir()): 378 + if not stream_dir.is_dir(): 379 + continue 380 + for segment_dir in sorted(stream_dir.iterdir()): 381 + if not segment_dir.is_dir(): 382 + continue 383 + start_time, end_time = segment_parse(segment_dir.name) 384 + if start_time is None or end_time is None: 385 + continue 386 + segments.append((stream_dir.name, segment_dir.name, start_time, end_time)) 387 + return segments 388 + 389 + 390 + def _parse_event_time(value: Any) -> time | None: 391 + """Parse an event time string if valid.""" 392 + if not isinstance(value, str) or not value: 393 + return None 394 + try: 395 + return time.fromisoformat(value) 396 + except ValueError: 397 + return None
+368
apps/speakers/tests/test_suggest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for speaker suggestion generation.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + from pathlib import Path 10 + 11 + import numpy as np 12 + 13 + 14 + def _write_discovery_cache(journal: Path, clusters: dict[str, list[dict]]) -> None: 15 + cache_dir = journal / "awareness" 16 + cache_dir.mkdir(parents=True, exist_ok=True) 17 + (cache_dir / "discovery_clusters.json").write_text( 18 + json.dumps({"version": "2026-03-19T00:00:00", "clusters": clusters}), 19 + encoding="utf-8", 20 + ) 21 + 22 + 23 + def _write_events(journal: Path, facet: str, day: str, events: list[dict]) -> None: 24 + events_dir = journal / "facets" / facet / "events" 25 + events_dir.mkdir(parents=True, exist_ok=True) 26 + lines = [json.dumps(event) for event in events] 27 + (events_dir / f"{day}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") 28 + 29 + 30 + def _write_voiceprints( 31 + journal: Path, entity_id: str, vectors: list[np.ndarray] 32 + ) -> None: 33 + entity_dir = journal / "entities" / entity_id 34 + entity_dir.mkdir(parents=True, exist_ok=True) 35 + metadata = np.array( 36 + [ 37 + json.dumps( 38 + { 39 + "day": "20240101", 40 + "segment_key": f"0900{idx:02d}_300", 41 + "source": "audio", 42 + "sentence_id": 1, 43 + "added_at": 1700000000000, 44 + } 45 + ) 46 + for idx, _ in enumerate(vectors, start=1) 47 + ], 48 + dtype=str, 49 + ) 50 + np.savez_compressed( 51 + entity_dir / "voiceprints.npz", 52 + embeddings=np.array(vectors, dtype=np.float32), 53 + metadata=metadata, 54 + ) 55 + 56 + 57 + def test_suggest_empty(speakers_env): 58 + """No suggestions when journal has no relevant data.""" 59 + speakers_env() 60 + 61 + from apps.speakers.suggest import suggest_speakers 62 + 63 + result = suggest_speakers() 64 + 65 + assert result == [] 66 + 67 + 68 + def test_suggest_unknown_recurring_from_cache(speakers_env): 69 + env = speakers_env() 70 + _write_discovery_cache( 71 + env.journal, 72 + { 73 + "7": [ 74 + { 75 + "day": "20240101", 76 + "stream": "test", 77 + "segment_key": "090000_1800", 78 + "source": "audio", 79 + "sentence_id": 1, 80 + }, 81 + { 82 + "day": "20240101", 83 + "stream": "test", 84 + "segment_key": "090000_1800", 85 + "source": "audio", 86 + "sentence_id": 2, 87 + }, 88 + { 89 + "day": "20240101", 90 + "stream": "test", 91 + "segment_key": "100000_1800", 92 + "source": "audio", 93 + "sentence_id": 3, 94 + }, 95 + ] 96 + }, 97 + ) 98 + 99 + from apps.speakers.suggest import suggest_speakers 100 + 101 + result = suggest_speakers(limit=10) 102 + 103 + assert result[0]["type"] == "unknown_recurring" 104 + assert result[0]["cluster_id"] == 7 105 + assert result[0]["size"] == 3 106 + assert result[0]["segment_count"] == 2 107 + assert result[0]["segments"] == [ 108 + "20240101/test/090000_1800", 109 + "20240101/test/100000_1800", 110 + ] 111 + assert "calendar_overlap" in result[0]["import_hints"] 112 + 113 + 114 + def test_suggest_unknown_recurring_calendar_overlap(speakers_env): 115 + env = speakers_env() 116 + env.create_segment("20240101", "090000_1800", ["audio"]) 117 + _write_discovery_cache( 118 + env.journal, 119 + { 120 + "2": [ 121 + { 122 + "day": "20240101", 123 + "stream": "test", 124 + "segment_key": "090000_1800", 125 + "source": "audio", 126 + "sentence_id": 1, 127 + } 128 + ] 129 + }, 130 + ) 131 + _write_events( 132 + env.journal, 133 + "testfacet", 134 + "20240101", 135 + [ 136 + { 137 + "type": "meeting", 138 + "start": "09:15:00", 139 + "end": "09:45:00", 140 + "title": "Design Sync", 141 + "facet": "testfacet", 142 + "participants": ["Alice", "Bob"], 143 + "occurred": True, 144 + } 145 + ], 146 + ) 147 + 148 + from apps.speakers.suggest import suggest_speakers 149 + 150 + result = suggest_speakers(limit=10) 151 + overlap = result[0]["import_hints"]["calendar_overlap"][0] 152 + 153 + assert overlap["title"] == "Design Sync" 154 + assert overlap["facet"] == "testfacet" 155 + assert overlap["participants"] == ["Alice", "Bob"] 156 + assert overlap["segments"] == ["20240101/test/090000_1800"] 157 + 158 + 159 + def test_suggest_import_linkable(speakers_env): 160 + env = speakers_env() 161 + env.create_segment("20240101", "090000_1800", ["audio"]) 162 + env.create_segment("20240101", "120000_1800", ["audio"]) 163 + env.create_entity("Has Voiceprint") 164 + env.create_entity("Needs Import") 165 + _write_voiceprints( 166 + env.journal, 167 + "has_voiceprint", 168 + [env.create_embedding([1.0, 0.0])], 169 + ) 170 + _write_events( 171 + env.journal, 172 + "work", 173 + "20240101", 174 + [ 175 + { 176 + "type": "meeting", 177 + "start": "09:15:00", 178 + "end": "09:45:00", 179 + "title": "Planning", 180 + "facet": "work", 181 + "participants": ["Has Voiceprint", "Needs Import"], 182 + "occurred": True, 183 + }, 184 + { 185 + "type": "meeting", 186 + "start": "12:15:00", 187 + "end": "12:45:00", 188 + "title": "Followup", 189 + "facet": "work", 190 + "participants": ["Needs Import"], 191 + "occurred": True, 192 + }, 193 + ], 194 + ) 195 + 196 + from apps.speakers.suggest import suggest_speakers 197 + 198 + result = suggest_speakers(limit=10) 199 + import_linkable = [s for s in result if s["type"] == "import_linkable"] 200 + 201 + assert len(import_linkable) == 1 202 + assert import_linkable[0]["name"] == "Needs Import" 203 + assert import_linkable[0]["meetings_count"] == 2 204 + assert import_linkable[0]["has_voiceprint"] is False 205 + assert import_linkable[0]["overlapping_segments"] == [ 206 + "20240101/test/090000_1800", 207 + "20240101/test/120000_1800", 208 + ] 209 + 210 + 211 + def test_suggest_name_variant(speakers_env): 212 + env = speakers_env() 213 + env.create_entity("Owner Person", is_principal=True) 214 + env.create_entity("Alice", voiceprints=[("20240101", "090000_300", "audio", 1)]) 215 + env.create_entity( 216 + "Alice Johnson", 217 + voiceprints=[("20240101", "090000_300", "audio", 1)], 218 + ) 219 + shared = env.create_embedding([1.0, 0.0, 0.0]) 220 + _write_voiceprints(env.journal, "alice", [shared, shared]) 221 + _write_voiceprints(env.journal, "alice_johnson", [shared, shared]) 222 + 223 + from apps.speakers.suggest import suggest_speakers 224 + 225 + result = suggest_speakers(limit=10) 226 + variants = [s for s in result if s["type"] == "name_variant"] 227 + 228 + assert len(variants) == 1 229 + assert variants[0]["names"] == ["Alice", "Alice Johnson"] 230 + assert variants[0]["entity_ids"] == ["alice", "alice_johnson"] 231 + assert variants[0]["similarity"] >= 0.9 232 + 233 + 234 + def test_suggest_low_confidence_review(speakers_env): 235 + env = speakers_env() 236 + env.create_segment("20240102", "090000_1800", ["audio"], num_sentences=12) 237 + labels = [] 238 + for idx in range(1, 7): 239 + labels.append( 240 + { 241 + "sentence_id": idx, 242 + "speaker": f"speaker_{idx}", 243 + "confidence": "medium", 244 + "method": "acoustic", 245 + } 246 + ) 247 + for idx in range(7, 13): 248 + labels.append( 249 + { 250 + "sentence_id": idx, 251 + "speaker": None, 252 + "confidence": None, 253 + "method": None, 254 + } 255 + ) 256 + env.create_speaker_labels("20240102", "090000_1800", labels) 257 + 258 + from apps.speakers.suggest import suggest_speakers 259 + 260 + result = suggest_speakers(limit=10) 261 + review = [s for s in result if s["type"] == "low_confidence_review"] 262 + 263 + assert len(review) == 1 264 + assert review[0]["day"] == "20240102" 265 + assert review[0]["medium_count"] == 6 266 + assert review[0]["null_count"] == 6 267 + assert review[0]["segments_needing_review"] == ["20240102/test/090000_1800"] 268 + 269 + 270 + def test_suggest_limit_truncation(speakers_env): 271 + env = speakers_env() 272 + _write_discovery_cache( 273 + env.journal, 274 + { 275 + "1": [ 276 + { 277 + "day": "20240101", 278 + "stream": "test", 279 + "segment_key": "090000_1800", 280 + "source": "audio", 281 + "sentence_id": 1, 282 + } 283 + ] 284 + }, 285 + ) 286 + env.create_segment("20240102", "090000_1800", ["audio"], num_sentences=12) 287 + labels = [ 288 + { 289 + "sentence_id": idx, 290 + "speaker": None, 291 + "confidence": None, 292 + "method": None, 293 + } 294 + for idx in range(1, 13) 295 + ] 296 + env.create_speaker_labels("20240102", "090000_1800", labels) 297 + 298 + from apps.speakers.suggest import suggest_speakers 299 + 300 + result = suggest_speakers(limit=1) 301 + 302 + assert len(result) == 1 303 + assert result[0]["type"] == "unknown_recurring" 304 + 305 + 306 + def test_suggest_priority_ordering(speakers_env): 307 + env = speakers_env() 308 + env.create_segment("20240101", "090000_1800", ["audio"]) 309 + _write_discovery_cache( 310 + env.journal, 311 + { 312 + "4": [ 313 + { 314 + "day": "20240101", 315 + "stream": "test", 316 + "segment_key": "090000_1800", 317 + "source": "audio", 318 + "sentence_id": 1, 319 + } 320 + ] 321 + }, 322 + ) 323 + env.create_entity("Import Target") 324 + _write_events( 325 + env.journal, 326 + "work", 327 + "20240101", 328 + [ 329 + { 330 + "type": "meeting", 331 + "start": "09:15:00", 332 + "end": "09:45:00", 333 + "title": "Planning", 334 + "facet": "work", 335 + "participants": ["Import Target"], 336 + "occurred": True, 337 + } 338 + ], 339 + ) 340 + env.create_entity("Owner Person", is_principal=True) 341 + env.create_entity("Bob", voiceprints=[("20240101", "090000_300", "audio", 1)]) 342 + env.create_entity( 343 + "Bob Smith", 344 + voiceprints=[("20240101", "090000_300", "audio", 1)], 345 + ) 346 + shared = env.create_embedding([0.0, 1.0, 0.0]) 347 + _write_voiceprints(env.journal, "bob", [shared, shared]) 348 + _write_voiceprints(env.journal, "bob_smith", [shared, shared]) 349 + env.create_segment("20240103", "090000_1800", ["audio"], num_sentences=12) 350 + review_labels = [ 351 + { 352 + "sentence_id": idx, 353 + "speaker": None, 354 + "confidence": None, 355 + "method": None, 356 + } 357 + for idx in range(1, 13) 358 + ] 359 + env.create_speaker_labels("20240103", "090000_1800", review_labels) 360 + 361 + from apps.speakers.suggest import suggest_speakers 362 + 363 + result = suggest_speakers(limit=50) 364 + types = [item["type"] for item in result] 365 + 366 + assert types.index("unknown_recurring") < types.index("import_linkable") 367 + assert types.index("import_linkable") < types.index("name_variant") 368 + assert types.index("name_variant") < types.index("low_confidence_review")