personal memory agent
0
fork

Configure Feed

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

Merge branch 'hopper-ykpvruoc-speakers-import-composition'

+674
+264
apps/speakers/bootstrap.py
··· 21 21 22 22 from __future__ import annotations 23 23 24 + import bisect 24 25 import json 25 26 import logging 26 27 from collections import defaultdict 28 + from pathlib import Path 27 29 from typing import Any 28 30 29 31 import numpy as np ··· 738 740 ) 739 741 740 742 return stats 743 + 744 + 745 + # Import streams that contain AI chat (no real speakers to seed) 746 + _AI_CHAT_STREAMS = frozenset({"import.chatgpt", "import.claude", "import.gemini"}) 747 + 748 + 749 + def _time_str_to_seconds(time_str: str) -> int: 750 + """Parse HH:MM:SS to total seconds.""" 751 + parts = time_str.split(":") 752 + return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) 753 + 754 + 755 + def _parse_conversation_speakers(seg_dir: Path) -> list[tuple[int, str]]: 756 + """Parse speaker assignments from conversation_transcript.jsonl. 757 + 758 + Returns sorted list of (start_seconds, speaker_name) tuples. 759 + Skips the metadata header line and entries with empty speaker fields. 760 + """ 761 + ct_path = seg_dir / "conversation_transcript.jsonl" 762 + if not ct_path.exists(): 763 + return [] 764 + 765 + entries: list[tuple[int, str]] = [] 766 + try: 767 + with open(ct_path, encoding="utf-8") as f: 768 + lines = f.readlines() 769 + except OSError: 770 + return [] 771 + 772 + # Skip header (line 0), parse entry lines 773 + for line in lines[1:]: 774 + try: 775 + entry = json.loads(line) 776 + except (json.JSONDecodeError, ValueError): 777 + continue 778 + speaker = entry.get("speaker", "") 779 + start = entry.get("start", "") 780 + if not speaker or not start: 781 + continue 782 + try: 783 + seconds = _time_str_to_seconds(start) 784 + except (ValueError, IndexError): 785 + continue 786 + entries.append((seconds, speaker)) 787 + 788 + entries.sort(key=lambda x: x[0]) 789 + return entries 790 + 791 + 792 + def _find_speaker_at_time( 793 + speaker_entries: list[tuple[int, str]], target_seconds: int 794 + ) -> str | None: 795 + """Find the speaker at a given time using binary search. 796 + 797 + Returns the speaker whose entry starts at or before target_seconds, 798 + or None if no entry precedes the target time. 799 + """ 800 + if not speaker_entries: 801 + return None 802 + # bisect on just the time values 803 + times = [e[0] for e in speaker_entries] 804 + idx = bisect.bisect_right(times, target_seconds) - 1 805 + if idx < 0: 806 + return None 807 + return speaker_entries[idx][1] 808 + 809 + 810 + def link_import(name: str, entity_id: str) -> dict[str, Any]: 811 + """Link an import participant name as an aka on an existing entity. 812 + 813 + Args: 814 + name: The participant name from an import transcript 815 + entity_id: The entity ID to link to 816 + 817 + Returns: 818 + Dict with link result or error 819 + """ 820 + entity = load_journal_entity(entity_id) 821 + if not entity: 822 + return {"error": f"Entity not found: {entity_id}"} 823 + 824 + existing_aka = set(entity.get("aka", [])) 825 + already_present = name in existing_aka 826 + 827 + if not already_present: 828 + existing_aka.add(name) 829 + entity["aka"] = sorted(existing_aka) 830 + entity["updated_at"] = now_ms() 831 + save_journal_entity(entity) 832 + 833 + return { 834 + "linked": True, 835 + "entity_id": entity_id, 836 + "name_added": name, 837 + "already_present": already_present, 838 + } 839 + 840 + 841 + def seed_from_imports(dry_run: bool = False) -> dict[str, Any]: 842 + """Seed voiceprints from import segments with speaker-attributed transcripts.""" 843 + ( 844 + load_embeddings_file, 845 + normalize_embedding, 846 + scan_segment_embeddings, 847 + _, 848 + ) = _routes_helpers() 849 + 850 + centroid_data = load_owner_centroid() 851 + if centroid_data is None: 852 + return {"error": "No confirmed owner centroid. Run owner detection first."} 853 + 854 + owner_centroid, owner_threshold = centroid_data 855 + 856 + journal_entities = load_all_journal_entities() 857 + entities_list = [e for e in journal_entities.values() if not e.get("blocked")] 858 + 859 + stats: dict[str, Any] = { 860 + "segments_scanned": 0, 861 + "segments_with_speakers": 0, 862 + "speakers_found": {}, 863 + "embeddings_saved": 0, 864 + "embeddings_skipped_owner": 0, 865 + "embeddings_skipped_duplicate": 0, 866 + "speakers_unmatched": [], 867 + "errors": [], 868 + } 869 + 870 + entity_embeddings: dict[str, list[tuple[np.ndarray, dict]]] = defaultdict(list) 871 + entity_existing: dict[str, set] = {} 872 + unmatched_set: set[str] = set() 873 + speaker_entity_cache: dict[str, Any] = {} 874 + 875 + days = sorted(day_dirs().keys()) 876 + 877 + for day_idx, day in enumerate(days): 878 + segments = scan_segment_embeddings(day) 879 + 880 + for segment in segments: 881 + stream = segment["stream"] 882 + if not stream.startswith("import.") or stream in _AI_CHAT_STREAMS: 883 + continue 884 + 885 + stats["segments_scanned"] += 1 886 + seg_key = segment["key"] 887 + seg_dir = segment_path(day, seg_key, stream) 888 + speaker_entries = _parse_conversation_speakers(seg_dir) 889 + if not speaker_entries: 890 + continue 891 + 892 + stats["segments_with_speakers"] += 1 893 + 894 + for source in segment["sources"]: 895 + source_jsonl = seg_dir / f"{source}.jsonl" 896 + stmt_times: dict[int, int] = {} 897 + if source_jsonl.exists(): 898 + try: 899 + with open(source_jsonl, encoding="utf-8") as f: 900 + src_lines = f.readlines() 901 + except OSError as exc: 902 + stats["errors"].append( 903 + f"Failed to read source transcript {day}/{seg_key}/{source}: {exc}" 904 + ) 905 + continue 906 + 907 + for i, line in enumerate(src_lines[1:], start=1): 908 + try: 909 + entry = json.loads(line) 910 + start = entry.get("start", "") 911 + if start: 912 + stmt_times[i] = _time_str_to_seconds(start) 913 + except (json.JSONDecodeError, ValueError, IndexError): 914 + continue 915 + 916 + emb_data = load_embeddings_file(seg_dir / f"{source}.npz") 917 + if emb_data is None: 918 + continue 919 + 920 + embeddings, statement_ids = emb_data 921 + 922 + for embedding, sid in zip(embeddings, statement_ids): 923 + sentence_id = int(sid) 924 + 925 + target_time = stmt_times.get(sentence_id) 926 + if target_time is None: 927 + continue 928 + 929 + speaker_name = _find_speaker_at_time(speaker_entries, target_time) 930 + if not speaker_name: 931 + continue 932 + 933 + if speaker_name not in speaker_entity_cache: 934 + entity = find_matching_entity(speaker_name, entities_list) 935 + speaker_entity_cache[speaker_name] = entity 936 + 937 + entity = speaker_entity_cache[speaker_name] 938 + if entity is None: 939 + if speaker_name not in unmatched_set: 940 + unmatched_set.add(speaker_name) 941 + stats["speakers_unmatched"].append(speaker_name) 942 + continue 943 + 944 + entity_id = entity["id"] 945 + entity_name = entity.get("name", speaker_name) 946 + stats["speakers_found"].setdefault(entity_name, 0) 947 + 948 + if entity_id not in entity_existing: 949 + entity_existing[entity_id] = _load_existing_voiceprint_keys( 950 + entity_id 951 + ) 952 + 953 + existing_keys = entity_existing[entity_id] 954 + vp_key = (day, seg_key, source, sentence_id) 955 + 956 + if vp_key in existing_keys: 957 + stats["embeddings_skipped_duplicate"] += 1 958 + continue 959 + 960 + normalized = normalize_embedding(embedding) 961 + if normalized is None: 962 + continue 963 + 964 + owner_score = float(np.dot(normalized, owner_centroid)) 965 + if owner_score >= owner_threshold: 966 + stats["embeddings_skipped_owner"] += 1 967 + continue 968 + 969 + metadata = { 970 + "day": day, 971 + "segment_key": seg_key, 972 + "source": source, 973 + "stream": stream, 974 + "sentence_id": sentence_id, 975 + "added_at": now_ms(), 976 + } 977 + 978 + entity_embeddings[entity_id].append((normalized, metadata)) 979 + existing_keys.add(vp_key) 980 + stats["speakers_found"][entity_name] += 1 981 + 982 + if (day_idx + 1) % 10 == 0: 983 + logger.info( 984 + "Import seeding progress: %d/%d days, %d segments, %d embeddings queued", 985 + day_idx + 1, 986 + len(days), 987 + stats["segments_scanned"], 988 + sum(len(v) for v in entity_embeddings.values()), 989 + ) 990 + 991 + if not dry_run: 992 + for entity_id, emb_list in entity_embeddings.items(): 993 + try: 994 + saved = _save_voiceprints_batch(entity_id, emb_list) 995 + stats["embeddings_saved"] += saved 996 + except Exception as e: 997 + stats["errors"].append(f"Failed to save for {entity_id}: {e}") 998 + logger.exception( 999 + "Failed to save import-seeded voiceprints for %s", entity_id 1000 + ) 1001 + else: 1002 + stats["embeddings_saved"] = sum(len(v) for v in entity_embeddings.values()) 1003 + 1004 + return stats
+82
apps/speakers/call.py
··· 12 12 sol call speakers discover [--json] 13 13 sol call speakers identify <cluster-id> <name> [--entity-id ID] 14 14 sol call speakers merge-names <alias> <canonical> 15 + sol call speakers link-import <name> --entity-id <ID> 16 + sol call speakers seed-from-imports [--dry-run] [--json] 15 17 sol call speakers suggest [--limit N] [--json] 16 18 sol call speakers link-import <name> --entity-id <ID> 17 19 sol call speakers seed-from-imports [--dry-run] [--json] ··· 385 387 typer.echo(output, err=True) 386 388 raise typer.Exit(1) 387 389 typer.echo(output) 390 + 391 + 392 + @app.command("link-import") 393 + def link_import_cmd( 394 + name: str = typer.Argument(..., help="Import participant name to link."), 395 + entity_id: str = typer.Option(..., "--entity-id", help="Entity ID to link to."), 396 + ) -> None: 397 + """Link an import participant name as an aka on an existing entity.""" 398 + import json 399 + 400 + from apps.speakers.bootstrap import link_import 401 + 402 + result = link_import(name, entity_id) 403 + output = json.dumps(result, indent=2, default=str) 404 + if "error" in result: 405 + typer.echo(output, err=True) 406 + raise typer.Exit(1) 407 + typer.echo(output) 408 + 409 + 410 + @app.command("seed-from-imports") 411 + def seed_from_imports_cmd( 412 + dry_run: bool = typer.Option( 413 + False, "--dry-run", help="Show what would be saved without saving." 414 + ), 415 + json_output: bool = typer.Option( 416 + False, "--json", help="Output full result as JSON." 417 + ), 418 + ) -> None: 419 + """Seed voiceprints from import segments with speaker-attributed transcripts. 420 + 421 + Scans import streams for segments with both conversation_transcript.jsonl 422 + (with speaker labels) and audio embeddings. Maps each embedding to a speaker 423 + via time-based alignment, matches speakers to existing entities, and saves 424 + embeddings as voiceprints with owner contamination guard. 425 + """ 426 + from apps.speakers.bootstrap import seed_from_imports 427 + 428 + if dry_run and not json_output: 429 + typer.echo("DRY RUN — no voiceprints will be saved\n") 430 + 431 + if not json_output: 432 + typer.echo("Seeding voiceprints from import segments...") 433 + stats = seed_from_imports(dry_run=dry_run) 434 + 435 + if "error" in stats: 436 + typer.echo(f"Error: {stats['error']}", err=True) 437 + raise typer.Exit(1) 438 + if json_output: 439 + import json as json_mod 440 + 441 + typer.echo(json_mod.dumps(stats, indent=2, default=str)) 442 + return 443 + 444 + typer.echo(f"\nSegments scanned: {stats['segments_scanned']}") 445 + typer.echo(f"Segments with speakers: {stats['segments_with_speakers']}") 446 + typer.echo(f"Unique speakers: {len(stats['speakers_found'])}") 447 + typer.echo(f"Embeddings saved: {stats['embeddings_saved']}") 448 + typer.echo(f"Embeddings skipped (owner): {stats['embeddings_skipped_owner']}") 449 + typer.echo( 450 + f"Embeddings skipped (duplicate): {stats['embeddings_skipped_duplicate']}" 451 + ) 452 + 453 + if stats["speakers_found"]: 454 + typer.echo("\nSpeakers by embedding count:") 455 + sorted_speakers = sorted( 456 + stats["speakers_found"].items(), key=lambda x: x[1], reverse=True 457 + ) 458 + for name, count in sorted_speakers[:15]: 459 + typer.echo(f" {name}: {count}") 460 + 461 + if stats["speakers_unmatched"]: 462 + typer.echo(f"\nUnmatched speakers ({len(stats['speakers_unmatched'])}):") 463 + for name in stats["speakers_unmatched"]: 464 + typer.echo(f" {name}") 465 + 466 + if stats["errors"]: 467 + typer.echo(f"\nErrors ({len(stats['errors'])}):", err=True) 468 + for err in stats["errors"]: 469 + typer.echo(f" {err}", err=True) 388 470 389 471 390 472 @app.command()
+94
apps/speakers/tests/conftest.py
··· 310 310 311 311 return labels_path 312 312 313 + def create_import_segment( 314 + self, 315 + day: str, 316 + segment_key: str, 317 + speakers: list[tuple[str, str]], 318 + *, 319 + stream: str = "import.granola", 320 + embeddings: np.ndarray | None = None, 321 + ) -> Path: 322 + """Create an import segment with conversation_transcript and embeddings. 323 + 324 + Creates both a conversation_transcript.jsonl (with speaker labels) and 325 + imported_audio.{jsonl,npz,flac} (with aligned embeddings) in the 326 + same segment directory. 327 + 328 + Args: 329 + day: Day string (YYYYMMDD) 330 + segment_key: Segment key (HHMMSS_LEN) 331 + speakers: List of (speaker_name, text) tuples for each sentence 332 + stream: Import stream name (default: import.granola) 333 + embeddings: Optional pre-built embeddings array (num_sentences x 256) 334 + """ 335 + segment_dir = self.journal / day / stream / segment_key 336 + segment_dir.mkdir(parents=True, exist_ok=True) 337 + 338 + num_sentences = len(speakers) 339 + 340 + time_part = segment_key.split("_")[0] 341 + base_h = int(time_part[0:2]) 342 + base_m = int(time_part[2:4]) 343 + base_s = int(time_part[4:6]) 344 + base_seconds = base_h * 3600 + base_m * 60 + base_s 345 + 346 + ct_lines = [ 347 + json.dumps({"imported": {"id": "test-import"}, "topics": "test"}) 348 + ] 349 + for i, (speaker, text) in enumerate(speakers): 350 + offset = i * 5 351 + abs_seconds = base_seconds + offset 352 + h = (abs_seconds // 3600) % 24 353 + m = (abs_seconds % 3600) // 60 354 + s = abs_seconds % 60 355 + ct_lines.append( 356 + json.dumps( 357 + { 358 + "start": f"{h:02d}:{m:02d}:{s:02d}", 359 + "speaker": speaker, 360 + "text": text, 361 + "source": "import", 362 + } 363 + ) 364 + ) 365 + ct_path = segment_dir / "conversation_transcript.jsonl" 366 + ct_path.write_text("\n".join(ct_lines) + "\n") 367 + 368 + audio_lines = [ 369 + json.dumps({"raw": "imported_audio.flac", "model": "medium.en"}) 370 + ] 371 + for i, (_speaker, text) in enumerate(speakers): 372 + offset = i * 5 373 + abs_seconds = base_seconds + offset 374 + h = (abs_seconds // 3600) % 24 375 + m = (abs_seconds % 3600) // 60 376 + s = abs_seconds % 60 377 + audio_lines.append( 378 + json.dumps( 379 + { 380 + "start": f"{h:02d}:{m:02d}:{s:02d}", 381 + "text": text, 382 + } 383 + ) 384 + ) 385 + audio_jsonl_path = segment_dir / "imported_audio.jsonl" 386 + audio_jsonl_path.write_text("\n".join(audio_lines) + "\n") 387 + 388 + if embeddings is None: 389 + source_embeddings = np.random.randn(num_sentences, 256).astype( 390 + np.float32 391 + ) 392 + norms = np.linalg.norm(source_embeddings, axis=1, keepdims=True) 393 + source_embeddings = source_embeddings / norms 394 + else: 395 + source_embeddings = embeddings.astype(np.float32) 396 + statement_ids = np.arange(1, num_sentences + 1, dtype=np.int32) 397 + np.savez_compressed( 398 + segment_dir / "imported_audio.npz", 399 + embeddings=source_embeddings, 400 + statement_ids=statement_ids, 401 + ) 402 + 403 + (segment_dir / "imported_audio.flac").write_bytes(b"") 404 + 405 + return segment_dir 406 + 313 407 def _create(): 314 408 return SpeakersEnv(tmp_path) 315 409
+234
apps/speakers/tests/test_import_composition.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for import-linking and import-based voiceprint seeding.""" 5 + 6 + from __future__ import annotations 7 + 8 + import json 9 + 10 + import numpy as np 11 + from typer.testing import CliRunner 12 + 13 + from apps.speakers.bootstrap import link_import, seed_from_imports 14 + from apps.speakers.call import app as speakers_app 15 + from think.entities.journal import load_journal_entity 16 + 17 + _runner = CliRunner() 18 + 19 + 20 + def _normalized_vector(seed: int) -> np.ndarray: 21 + rng = np.random.default_rng(seed) 22 + vec = rng.standard_normal(256).astype(np.float32) 23 + return vec / np.linalg.norm(vec) 24 + 25 + 26 + def _mock_owner(monkeypatch, vector: np.ndarray, threshold: float = 0.85) -> None: 27 + monkeypatch.setattr( 28 + "apps.speakers.bootstrap.load_owner_centroid", 29 + lambda: (vector.astype(np.float32), threshold), 30 + ) 31 + 32 + 33 + def test_link_import_success(speakers_env): 34 + env = speakers_env() 35 + env.create_entity("Alice Test") 36 + 37 + result = link_import("Alice Imported", "alice_test") 38 + 39 + assert result["linked"] is True 40 + assert result["already_present"] is False 41 + entity = load_journal_entity("alice_test") 42 + assert entity is not None 43 + assert "Alice Imported" in entity.get("aka", []) 44 + 45 + 46 + def test_link_import_entity_not_found(speakers_env): 47 + speakers_env() 48 + 49 + result = link_import("Alice", "nonexistent") 50 + 51 + assert "error" in result 52 + 53 + 54 + def test_link_import_already_present(speakers_env): 55 + env = speakers_env() 56 + entity_dir = env.create_entity("Alice Test") 57 + entity_path = entity_dir / "entity.json" 58 + entity = json.loads(entity_path.read_text(encoding="utf-8")) 59 + entity["aka"] = ["Alice Imported"] 60 + entity_path.write_text(json.dumps(entity), encoding="utf-8") 61 + 62 + result = link_import("Alice Imported", "alice_test") 63 + 64 + assert result["linked"] is True 65 + assert result["already_present"] is True 66 + 67 + 68 + def test_seed_from_imports_basic(speakers_env, monkeypatch): 69 + env = speakers_env() 70 + env.create_entity("Alice Test") 71 + embeddings = np.vstack([_normalized_vector(i) for i in range(3)]).astype(np.float32) 72 + env.create_import_segment( 73 + "20240101", 74 + "120000_300", 75 + [("Alice Test", "Hello")] * 3, 76 + embeddings=embeddings, 77 + ) 78 + _mock_owner(monkeypatch, _normalized_vector(99), threshold=0.99) 79 + 80 + result = seed_from_imports() 81 + 82 + assert result["segments_scanned"] == 1 83 + assert result["segments_with_speakers"] == 1 84 + assert result["embeddings_saved"] == 3 85 + assert result["speakers_found"] == {"Alice Test": 3} 86 + vp_path = env.journal / "entities" / "alice_test" / "voiceprints.npz" 87 + assert vp_path.exists() 88 + 89 + 90 + def test_seed_from_imports_dry_run(speakers_env, monkeypatch): 91 + env = speakers_env() 92 + env.create_entity("Alice Test") 93 + embeddings = np.vstack([_normalized_vector(i) for i in range(3)]).astype(np.float32) 94 + env.create_import_segment( 95 + "20240101", 96 + "120000_300", 97 + [("Alice Test", "Hello")] * 3, 98 + embeddings=embeddings, 99 + ) 100 + _mock_owner(monkeypatch, _normalized_vector(77), threshold=0.99) 101 + 102 + result = seed_from_imports(dry_run=True) 103 + 104 + assert result["embeddings_saved"] == 3 105 + vp_path = env.journal / "entities" / "alice_test" / "voiceprints.npz" 106 + assert not vp_path.exists() 107 + 108 + 109 + def test_seed_from_imports_owner_skip(speakers_env, monkeypatch): 110 + env = speakers_env() 111 + env.create_entity("Alice Test") 112 + owner_vec = _normalized_vector(123) 113 + embeddings = np.vstack([owner_vec, owner_vec, owner_vec]).astype(np.float32) 114 + env.create_import_segment( 115 + "20240101", 116 + "120000_300", 117 + [("Alice Test", "Hello")] * 3, 118 + embeddings=embeddings, 119 + ) 120 + _mock_owner(monkeypatch, owner_vec, threshold=0.8) 121 + 122 + result = seed_from_imports() 123 + 124 + assert result["embeddings_saved"] == 0 125 + assert result["embeddings_skipped_owner"] == 3 126 + 127 + 128 + def test_seed_from_imports_dedup(speakers_env, monkeypatch): 129 + env = speakers_env() 130 + env.create_entity("Alice Test") 131 + embeddings = np.vstack([_normalized_vector(i) for i in range(3)]).astype(np.float32) 132 + env.create_import_segment( 133 + "20240101", 134 + "120000_300", 135 + [("Alice Test", "Hello")] * 3, 136 + embeddings=embeddings, 137 + ) 138 + _mock_owner(monkeypatch, _normalized_vector(88), threshold=0.99) 139 + 140 + first = seed_from_imports() 141 + second = seed_from_imports() 142 + 143 + assert first["embeddings_saved"] == 3 144 + assert second["embeddings_saved"] == 0 145 + assert second["embeddings_skipped_duplicate"] == 3 146 + 147 + 148 + def test_seed_from_imports_unmatched_speaker(speakers_env, monkeypatch): 149 + env = speakers_env() 150 + embeddings = np.vstack([_normalized_vector(i) for i in range(2)]).astype(np.float32) 151 + env.create_import_segment( 152 + "20240101", 153 + "120000_300", 154 + [("Unknown Person", "Hello")] * 2, 155 + embeddings=embeddings, 156 + ) 157 + _mock_owner(monkeypatch, _normalized_vector(55), threshold=0.99) 158 + 159 + result = seed_from_imports() 160 + 161 + assert result["embeddings_saved"] == 0 162 + assert result["speakers_unmatched"] == ["Unknown Person"] 163 + 164 + 165 + def test_seed_from_imports_ai_chat_skip(speakers_env, monkeypatch): 166 + env = speakers_env() 167 + env.create_entity("Alice Test") 168 + embeddings = np.vstack([_normalized_vector(i) for i in range(2)]).astype(np.float32) 169 + env.create_import_segment( 170 + "20240101", 171 + "120000_300", 172 + [("Alice Test", "Hello")] * 2, 173 + stream="import.chatgpt", 174 + embeddings=embeddings, 175 + ) 176 + _mock_owner(monkeypatch, _normalized_vector(44), threshold=0.99) 177 + 178 + result = seed_from_imports() 179 + 180 + assert result["segments_scanned"] == 0 181 + assert result["segments_with_speakers"] == 0 182 + assert result["embeddings_saved"] == 0 183 + 184 + 185 + def test_seed_from_imports_empty_speaker(speakers_env, monkeypatch): 186 + env = speakers_env() 187 + env.create_entity("Alice Test") 188 + embeddings = np.vstack([_normalized_vector(i) for i in range(3)]).astype(np.float32) 189 + env.create_import_segment( 190 + "20240101", 191 + "120000_300", 192 + [("", "skip one"), ("", "skip two"), ("Alice Test", "keep this")], 193 + embeddings=embeddings, 194 + ) 195 + _mock_owner(monkeypatch, _normalized_vector(33), threshold=0.99) 196 + 197 + result = seed_from_imports() 198 + 199 + assert result["embeddings_saved"] == 1 200 + assert result["speakers_found"] == {"Alice Test": 1} 201 + 202 + 203 + def test_link_import_cli_json_success(speakers_env): 204 + env = speakers_env() 205 + env.create_entity("Alice Test") 206 + 207 + result = _runner.invoke( 208 + speakers_app, 209 + ["link-import", "Alice Imported", "--entity-id", "alice_test"], 210 + ) 211 + 212 + assert result.exit_code == 0 213 + data = json.loads(result.output) 214 + assert data["linked"] is True 215 + assert data["entity_id"] == "alice_test" 216 + 217 + 218 + def test_seed_from_imports_cli_json_success(speakers_env, monkeypatch): 219 + env = speakers_env() 220 + env.create_entity("Alice Test") 221 + embeddings = np.vstack([_normalized_vector(i) for i in range(2)]).astype(np.float32) 222 + env.create_import_segment( 223 + "20240101", 224 + "120000_300", 225 + [("Alice Test", "Hello")] * 2, 226 + embeddings=embeddings, 227 + ) 228 + _mock_owner(monkeypatch, _normalized_vector(22), threshold=0.99) 229 + 230 + result = _runner.invoke(speakers_app, ["seed-from-imports", "--json"]) 231 + 232 + assert result.exit_code == 0 233 + data = json.loads(result.output) 234 + assert data["embeddings_saved"] == 2